Skip to main content

Spring AI: Building an MCP Server and Client with Spring AI

Greetings!

In the previous article, we learned the concepts behind Model Context Protocol (MCP). In this article, we'll focus on Spring AI and see how easily it can expose Java methods as MCP tools and consume remote MCP servers.

Architecture

Part 1 - MCP Server

Dependency

Note that we don't need AI models in the MCP server.

gradle
implementation "org.springframework.ai:spring-ai-starter-mcp-server-webmvc"

Configuration

yaml
server:
  port: 8080

spring:
  application:
    name: movie-mcp-server

  ai:
    mcp:
      server:
        name: movie-mcp-server
        version: 1.0.0
        type: SYNC
        protocol: STREAMABLE
        instructions: >
          Provides tools for listing, finding, searching, and filtering movies.
        streamable-http:
          mcp-endpoint: /mcp

Repository

java
public record Movie(
        Long id,
        String title,
        String director,
        int releaseYear,
        String genre,
        double rating
) {
}
java
@Repository
public class MovieRepository {

    private final List<Movie> movies = List.of(
            new Movie(1L, "The Shawshank Redemption", "Frank Darabont", 1994, "Drama", 9.3),
            new Movie(2L, "The Godfather", "Francis Ford Coppola", 1972, "Crime", 9.2),
            new Movie(3L, "The Dark Knight", "Christopher Nolan", 2008, "Action", 9.0),
            new Movie(4L, "Inception", "Christopher Nolan", 2010, "Science Fiction", 8.8),
            new Movie(5L, "Interstellar", "Christopher Nolan", 2014, "Science Fiction", 8.7),
            new Movie(6L, "Parasite", "Bong Joon Ho", 2019, "Thriller", 8.5),
            new Movie(7L, "Spirited Away", "Hayao Miyazaki", 2001, "Animation", 8.6),
            new Movie(8L, "Gladiator", "Ridley Scott", 2000, "Action", 8.5)
    );

    public List<Movie> findAll() {
        return movies;
    }

    public Optional<Movie> findById(Long id) {
        return movies.stream()
                .filter(movie -> movie.id().equals(id))
                .findFirst();
    }

    public List<Movie> searchByTitle(String title) {
        String value = title.toLowerCase(Locale.ROOT);

        return movies.stream()
                .filter(movie -> movie.title().toLowerCase(Locale.ROOT).contains(value))
                .toList();
    }
}

Tool

java
@Component
public class MovieTools {

    private final MovieRepository movieRepository;

    public MovieTools(MovieRepository movieRepository) {
        this.movieRepository = movieRepository;
    }

    @McpTool(
            name = "listMovies",
            description = "List all movies in the catalog",
            generateOutputSchema = true
    )
    public List<Movie> listMovies() {
        return movieRepository.findAll();
    }

    @McpTool(
            name = "getMovie",
            description = "Find a movie by its numeric ID",
            generateOutputSchema = true
    )
    public Movie getMovie(
            @McpToolParam(
                    description = "Numeric movie ID",
                    required = true
            )
            Long id
    ) {
        return movieRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("Movie not found: " + id));
    }

    @McpTool(
            name = "searchMovies",
            description = "Search for movies whose titles contain the supplied text",
            generateOutputSchema = true
    )
    public List<Movie> searchMovies(
            @McpToolParam(
                    description = "Text contained in the movie title",
                    required = true
            )
            String title
    ) {
        return movieRepository.searchByTitle(title);
    }
}

Spring AI automatically discovers the methods, creates tool schemas, exposes them via MCP and handles JSON-RPC.

Part 2 - MCP Client

Dependencies

gradle
implementation "org.springframework.ai:spring-ai-starter-model-openai"
implementation "org.springframework.ai:spring-ai-starter-mcp-client"

Configuration

yaml
server:
  port: 8081

spring:
  application:
    name: movie-mcp-chat-client
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}

    mcp:
      client:
        enabled: true
        name: movie-chat-client
        version: 1.0.0
        type: SYNC
        initialized: true
        request-timeout: 30s
        toolcallback:
          enabled: true
        streamable-http:
          connections:
            movie-server:
              url: http://localhost:8080
              endpoint: /mcp

Register Tools

java
@Configuration
public class ChatClientConfig {

    @Bean
    ChatClient movieChatClient(
            ChatClient.Builder builder,
            SyncMcpToolCallbackProvider mcpToolCallbackProvider
    ) {
        return builder
                .defaultSystem("""
                        You are a movie assistant.

                        Use the available movie tools whenever the user asks about
                        movies in the catalog. Do not invent catalog entries,
                        ratings, genres, release years, directors, or IDs.

                        When no matching movie is returned, clearly say that no
                        matching movie was found in the catalog.
                        """)
                .defaultTools(mcpToolCallbackProvider)
                .build();
    }
}

Call the Model

java
@Service
public class MovieChatService {

    private final ChatClient chatClient;

    public MovieChatService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    public String chat(String question) {
        return chatClient.prompt()
                .user(question)
                .call()
                .content();
    }
}
java
@RestController
@RequestMapping("/chat")
public class MovieChatController {

    private final MovieChatService movieChatService;

    public MovieChatController(MovieChatService movieChatService) {
        this.movieChatService = movieChatService;
    }

    @PostMapping
    public ChatResponse chat(@RequestBody ChatRequest request) {
        return new ChatResponse(movieChatService.chat(request.question()));
    }
}
java
public record ChatRequest(String question) {
}

public record ChatResponse(String answer) {
}

What Spring AI Handles

  • Tool discovery
  • JSON-RPC
  • Capability discovery
  • Tool execution
  • Serialization
  • Transport
  • Session management

You mainly write @McpTool on the server and .defaultTools(toolProvider) on the client.

Testing the Server Independently

Now we can chat with the client just like any other AI agent. Before doing that, let's see how to test the MCP server independently. Although the MCP Inspector is the easiest way to test an MCP server, I'll use curl commands here so we can better understand the complete request and response flow.

bash
export MCP_URL="http://localhost:8080/mcp"
export PROTOCOL_VERSION="2025-11-25"
export HEADERS_FILE="/tmp/movie-mcp-headers.txt"

Initialize

bash
curl -i \
  -D "$HEADERS_FILE" \
  -X POST "$MCP_URL" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: $PROTOCOL_VERSION" \
  --data '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-11-25",
      "capabilities": {},
      "clientInfo": {
        "name": "curl-client",
        "version": "1.0.0"
      }
    }
  }'

Extract session id

bash
export MCP_SESSION_ID="b527c982-1bdd-41ce-b125-f225edf4331c"

Send initialized notification

bash
curl -i \
  -X POST "$MCP_URL" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: $PROTOCOL_VERSION" \
  -H "Mcp-Session-Id: $MCP_SESSION_ID" \
  --data '{
    "jsonrpc": "2.0",
    "method": "notifications/initialized"
  }'

List tools

bash
curl -sS \
  -X POST "$MCP_URL" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: $PROTOCOL_VERSION" \
  -H "Mcp-Session-Id: $MCP_SESSION_ID" \
  --data '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/list",
    "params": {}
  }'

List all movies

bash
curl -sS \
  -X POST "$MCP_URL" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: $PROTOCOL_VERSION" \
  -H "Mcp-Session-Id: $MCP_SESSION_ID" \
  --data '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
      "name": "listMovies",
      "arguments": {}
    }
  }'

Get movie by id

bash
curl -sS \
  -X POST "$MCP_URL" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: $PROTOCOL_VERSION" \
  -H "Mcp-Session-Id: $MCP_SESSION_ID" \
  --data '{
    "jsonrpc": "2.0",
    "id": 4,
    "method": "tools/call",
    "params": {
      "name": "getMovie",
      "arguments": {
        "id": 4
      }
    }
  }'

Close the session

bash
curl -i \
  -X DELETE "$MCP_URL" \
  -H "MCP-Protocol-Version: $PROTOCOL_VERSION" \
  -H "Mcp-Session-Id: $MCP_SESSION_ID"

Summary

Spring AI makes building both an MCP server and an MCP client remarkably simple by hiding the protocol details while letting developers focus on business logic.

Comments