Skip to content

MCP Basics and the Spring AI MCP Client

The tools covered up to the previous article all ran inside the application. The format for describing tools to a model, however, differs from provider to provider. OpenAI uses "type": "function", Anthropic uses input_schema, and Google Gemini uses function_declarations. To switch models or support several of them, you have to rewrite your tool definitions.

MCP (Model Context Protocol), which Anthropic released in November 2024, standardizes how AI applications connect to external systems with a single open protocol. The AI application gets an MCP client, the external system gets an MCP server, and the two communicate through standard messages. By analogy with one connector replacing a jumble of different charging ports, it is also called a "USB-C port for AI."

As the first article of Chapter 5, this article summarizes the structure, primitives, and transports of MCP, then uses the chapter5 example code to show how to configure and extend the Spring AI MCP client.

Hosts, clients, and servers

Participants in MCP take one of three roles.

MCP client-server architecture

MCP client-server architecture (Source: Connect Your AI to Everything: Spring AI's MCP Boot Starters)
  • MCP host: An AI application such as Claude Desktop or Visual Studio Code. It creates and coordinates an MCP client for each external system it connects to.
  • MCP client: Maintains a dedicated connection to a single server from within the host. With three servers there are three clients, so context and permissions are kept separate for each connection.
  • MCP server: An independent program that provides data or tools. It connects to multiple clients at the same time (1:N) and keeps a separate session for each connection.

Because each server runs in its own process, even a compromised GitHub server would have a hard time affecting the permissions of the database server. Each server gets only the minimum permissions it needs for the system it is responsible for, and the host keeps track of which server has which tools and sends each request to the right client. MCP is a protocol that maintains sessions, so servers isolate the session of each client, and clients handle timeouts, errors, and reconnection. Thanks to this division of roles, a server built once to the standard can be used from any host, and a host connects to any server with the same client implementation, regardless of the language the server is written in.

Primitives: the units of functionality exchanged

The units of functionality that clients and servers exchange are called primitives. They fall into three groups, depending on who offers a feature and who calls it.

Direction Primitives Used for
Offered by the server, called by the client Tools, resources, prompts, completions Performing actions, read-only context, reusable templates, argument suggestions
Offered by the client, called by the server Sampling, elicitation, roots Borrowing the client's LLM, user input and approval, setting the access scope
Both directions Notifications, progress, ping List change notifications, progress updates, connection checks

Sampling lets a server use the client's model without an API key of its own.

The Java MCP stack and the client's role

The MCP specification divides the protocol into a data layer and a transport layer, and the MCP Java SDK implements this as a stack with the same shape on both the client and the server.

MCP stack architecture

MCP stack architecture (Source: Model Context Protocol (MCP), Spring AI Reference)
  • Client and server layer (data layer): Works with primitives. The client requests tool execution or resource lookups based on the model's decisions, and the server exposes and executes primitives.
  • Session layer (data layer): Handles routing of JSON-RPC 2.0 messages, matching of request and response IDs, and error handling. McpClientSession negotiates the version and capabilities with an initialize request, and once the server responds, it signals that it is ready with an initialized notification. McpServerSession validates initialization requests and manages the sessions of multiple clients separately.
  • Transport layer: Abstracted by the McpTransport interface, it serializes messages and sends and receives them over the actual channel. On the client side, it decides whether to connect to a remote URL or launch a local process, and on the server side, it opens an endpoint and waits.

There are three transport protocols. STDIO connects two processes on the same machine through standard input and output, without going over the network. Streamable HTTP is for remote use: a single endpoint accepts POST and GET requests and streams over SSE when needed. HTTP with SSE is the remote transport used in the 2024-11-05 specification. Starting with the 2025-03-26 specification it was replaced by Streamable HTTP, but SDKs continue to support it for backward compatibility. Because the layers are separate, switching the transport leaves the logic above it unchanged.

In this structure, the model does not talk to servers directly. Instead, a Java MCP client attached to each server handles the connection. The client negotiates the protocol version, retrieves the lists of tools, resources, and prompts, and passes request results to the application. With the STDIO transport, it also launches the server as a child process. Remote and local servers are handled the same way, so you can focus on what a tool does rather than where it runs.

Direct tool calling and MCP tool calling

Both approaches give the model a list of tools with their JSON schemas and let the model choose. What differs is where the tools are implemented and how they are called.

With direct tool calling, the tools live in the same JVM as the application, so calls are fast, and the tools can use Spring transactions and user sessions right away. However, the tools also have to be written in Java, other teams have to reimplement them to use them, and even when the load falls on a single tool, you have to scale the entire application.

MCP tool calling splits tools out into independent servers. When the model requests a tool, the client hands the execution to the server over JSON-RPC and returns the result to the model. You can call a Python analysis tool as if it were a local function, and several systems can share a server, but this comes at a cost: running and monitoring the servers, and preparing for latency and network failures.

This choice resembles the trade-offs of moving from monoliths to microservices. The book recommends starting with direct tool calling and splitting tools out into an MCP server once you need to share tools, use other languages, or separate permissions. Spring AI supports both approaches, so you can also expose existing tool code as an MCP server by adding dependencies and configuration.

MCP client Boot starters and common settings

Spring AI has two MCP client Boot starters: spring-ai-starter-mcp-client, based on the JDK HttpClient, and spring-ai-starter-mcp-client-webflux, based on WebFlux. They differ only in their transport implementation, and both support STDIO, Streamable HTTP, and sync and async modes equally. For a typical Spring MVC environment, choose the standard starter, and if the whole stack is reactive, choose the WebFlux starter. chapter5 uses the standard starter and keeps all of its settings under spring.ai.mcp.client.

application.yml
    mcp:
      client:
        enabled: true
        name: chapter5-mcp-client
        version: 0.1.0
        type: SYNC
        initialized: true
        request-timeout: 30s
        toolcallback:
          enabled: true
        streamable-http:
          connections:
            rag:
              url: http://localhost:8085
              endpoint: /mcp
View full code

The example also writes out properties that are at their default values, and changes only name, version, and request-timeout (from the default 20 seconds to 30 seconds). Three properties have a large effect on behavior.

  • type: SYNC (default) or ASYNC. Whereas the starter decides the transport implementation, this value decides whether the client waits for responses or receives them as reactive types. It applies to the entire application as a single choice, and which annotation handlers get registered also depends on this value.
  • initialized: When true (default), the handshake happens when the bean is created, so startup fails if the server is down. When false, the application starts, but you have to call initialize() yourself at the point you need it.
  • toolcallback.enabled: When true (default), a SyncMcpToolCallbackProvider bean (AsyncMcpToolCallbackProvider for async) that gathers the server tools as ToolCallbacks is registered. If you turn it off, this bean is not created, so you have to call tools directly through the client.

Connecting to STDIO and remote servers

Each connection is declared under the prefix for its transport, with a name as the key. This name also serves as the identifier that customizers and annotations use to point to the target server.

For STDIO, write command, args, and env under stdio.connections.<name>, or use servers-configuration to point to a JSON file in the Claude Desktop format. On Windows, npx is a batch file that ProcessBuilder cannot run directly, so wrap it with cmd.exe /c. If your team works on a mix of operating systems, have each profile read a different JSON file.

For a remote server, use url and endpoint (default /mcp) under streamable-http.connections.<name> for Streamable HTTP, or url and sse-endpoint (default /sse) under sse.connections.<name> for HTTP with SSE. Put only the scheme, host, and port in url, and put the path and query parameters in the endpoint value, which starts with /. If you get a 404 when connecting, check this split first.

There was a discussion (issue #3948, PR #3949) about adding a headers field under the connection settings for authenticating with remote servers, but the Spring AI team decided not to broaden the scope of the configuration. The reasons were that tokens are short-lived and rotate periodically, each request may need to carry different user permissions, and authentication methods differ from server to server. You can add fixed headers with a McpSyncHttpClientRequestCustomizer bean, but the same token then goes out to every server, so this is better suited to local testing. OAuth2-based security is covered in the MCP security article.

Auto-configured clients and tool callback providers

Depending on type, the auto-configured clients are injected as List<McpSyncClient> or List<McpAsyncClient>, and the provider that gathers the tools of all servers is injected as SyncMcpToolCallbackProvider or AsyncMcpToolCallbackProvider. The example uses the sync setting, so its McpClientCatalogService receives List<McpSyncClient> and SyncMcpToolCallbackProvider through its constructor.

McpClientCatalogService.java
@Service
@Profile("!server")
public class McpClientCatalogService {

    private final List<McpSyncClient> mcpClients;
    private final SyncMcpToolCallbackProvider toolCallbackProvider;

    public McpClientCatalogService(
            List<McpSyncClient> mcpClients,
            SyncMcpToolCallbackProvider toolCallbackProvider) {
        this.mcpClients = mcpClients;
        this.toolCallbackProvider = toolCallbackProvider;
    }

    public String serverSummary() {
        McpSyncClient client = primaryClient();
        McpSchema.Implementation serverInfo = client.getServerInfo();
        McpSchema.ServerCapabilities capabilities = client.getServerCapabilities();
        return """
                Server: %s %s
                Instructions: %s
                Capabilities: %s
                """.formatted(
                serverInfo.name(),
                serverInfo.version(),
                client.getServerInstructions(),
                capabilities);
    }

    public String listTools() {
        return primaryClient().listTools().tools().stream()
                .map(tool -> "- %s: %s".formatted(tool.name(), tool.description()))
                .reduce((left, right) -> left + "\n" + right)
                .orElse("노출된 MCP 툴이 없습니다.");
    }
View full code

serverSummary() fetches the server information and capabilities received during initialization, and listTools() fetches the tool list. Client Step 1 (Ch5Step1_McpDiscovery) prints these together with the resource and prompt lists, and Step 2 (Ch5Step2_McpPrimitiveCalls) uses the same service to call callTool, readResource, getPrompt, and completeCompletion, trying out primitives beyond tools.

The callbacks the provider returns are ordinary Spring AI ToolCallbacks. Step 3 takes rag_search_documents from among them and runs it directly, without a model.

Ch5Step3_ToolCallbackProvider.java
    @Override
    public void run(String... args) {
        System.out.println("=== [Ch5] Client Step3: SyncMcpToolCallbackProvider ===");
        System.out.println(catalogService.listToolCallbacks());

        ToolCallback callback = catalogService.toolCallback("rag_search_documents");
        String result = callback.call("""
                {"query": "개인 정보가 포함된 문서는 어떻게 처리해야 하나요?", "topK": 3, "category": "tech_docs"}
                """, new ToolContext(Map.of(
                "userId", "step3-user",
                "conversationId", "step3-conversation",
                "clientSession", "ch5-client-step3"
        )));

        System.out.println("\n[Direct ToolCallback Result]");
        System.out.println(result);
    }
View full code

The ToolContext passed along with the JSON arguments is converted into metadata for the MCP tool call and sent to the server. To connect the tools to a model, register the result of getToolCallbacks() as tools on ChatClient. The final CLI's McpEnabledChatService passes it to defaultTools().

Customizers and tool policies

Servers, too, ask the client for sampling or elicitation and send it logs and progress updates. Spring AI opens up the handling of these messages and the tool exposure policies as extension points, and when implementation beans exist, auto-configuration applies them. The example's Chapter5McpClientConfiguration registers all four of the following as beans.

Chapter5McpClientConfiguration.java
    @Bean
    McpClientCustomizer<McpClient.SyncSpec> chapter5McpSyncClientCustomizer() {
        return (serverConfigurationName, spec) -> spec
                .requestTimeout(Duration.ofSeconds(30))
                .roots(new McpSchema.Root(workspaceRoot(), "chapter5 workspace"))
                .progressConsumer(progress -> logger.info("[{}] MCP progress: {} / {} {}",
                        serverConfigurationName,
                        progress.progress(),
                        progress.total(),
                        progress.message()))
                .loggingConsumer(logMessage -> logger.info("[{}] MCP log [{}] {}",
                        serverConfigurationName,
                        logMessage.level(),
                        logMessage.data()));
    }

    @Bean
    McpToolFilter chapter5McpToolFilter() {
        return (connectionInfo, tool) -> tool.name().startsWith("rag_")
                && (tool.description() == null || !tool.description().contains("experimental"));
    }

    @Bean
    McpToolNamePrefixGenerator chapter5McpToolNamePrefixGenerator() {
        return McpToolNamePrefixGenerator.noPrefix();
    }

    @Bean
    ToolContextToMcpMetaConverter chapter5ToolContextToMcpMetaConverter() {
        return this::toMcpMeta;
    }
View full code

  • Client behavior: A McpClientCustomizer<McpClient.SyncSpec> bean (McpClientCustomizer<McpClient.AsyncSpec> for async) receives the connection name and the spec, and registers the timeout, roots, and progress and logging consumers. sampling and elicitation handlers also go on the same spec, and by branching on the connection name, you can apply a different policy to each server.
  • Tool filter: McpToolFilter selects which tools to expose. The example keeps only tools whose names start with rag_ and whose descriptions do not contain experimental, and you can have only one filter bean.
  • Tool names: McpToolNamePrefixGenerator prevents name collisions between servers. The default implementation, DefaultMcpToolNamePrefixGenerator, adds a prefix such as alt_1_ to duplicate names, and the example uses noPrefix() because it has only one connection. If you use noPrefix() with multiple servers and names collide, an IllegalStateException is thrown.
  • Metadata: ToolContextToMcpMetaConverter moves the values in ToolContext, the tool execution context, into the metadata of the MCP tool call. The default, ToolContextToMcpMetaConverter.defaultConverter(), passes along every entry that has a value, so the example's toMcpMeta() lets through only userId, conversationId, clientSession, and progressToken.

Client Step 4 (Ch5Step4_McpClientPolicy) converts a ToolContext that includes rawSecret with this converter and prints the result. Because rawSecret is not on the allowlist, it does not appear in the result.

MCP client annotations

You can also use annotations to declare how server requests and notifications are handled. The client starter includes spring-ai-mcp-annotations, so when you put these annotations on methods of Spring beans, the scanner (annotation-scanner.enabled, default true) registers them.

Annotation Handles Return type (sync / async)
@McpSampling Sampling requests CreateMessageResult / Mono<CreateMessageResult>
@McpElicitation Elicitation requests ElicitResult / Mono<ElicitResult>
@McpLogging, @McpProgress, @McpToolListChanged, and others Log, progress, and list change notifications void / void or Mono<Void>

You specify the target server by connection name in the clients attribute, as in @McpLogging(clients = "rag"). This attribute is a string array, so you can also bind several connections to one method. If a class has both sync and async methods, only the methods whose return types match the type setting are registered.

However, annotations cannot set roots, and because the connection names are fixed in code, you cannot change the target at runtime. Also, auto-configuration registers the annotation handlers first and then runs the customizers, so if both set the same handler slot, such as sampling or elicitation, the customizer overwrites it. Annotations fit when the servers are fixed, and customizers fit when connections change often. Within a team, it helps to settle on one approach.

Where this fits in the 4-tier architecture

In the 4-tier architecture, T2 Orchestration and T3 Capability meet at the standard interface for calling capabilities. The book uses MCP for this interface. The MCP client turns remote T3 capabilities into ToolCallbacks so that the T2 tool loop calls them the same way it calls local tools. If you keep capabilities that run in the same process as local tools and split capabilities operated separately into MCP servers, the orchestration code stays the same even as you add capabilities. The next article, Building an MCP Server with Spring AI: From Boot Starters to Annotations, builds the MCP server on the other side of this boundary.

More in the book

Book sections 5.1-5.2

  • The exposure direction and a description of each of the 11 MCP primitives
  • A table comparing direct tool calling with an MCP-based architecture
  • A table of common configuration properties and the recommended environment for each of the two starters
  • STDIO JSON configuration, Windows path rules, and examples of profiles for each operating system
  • Guidelines for splitting remote URLs, routing custom headers per server, and complete customizer and annotation examples

About the book Buy the book (Korean)

References