MCP Client

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). The Model Context Protocol lets an application use the tools, resources and prompts of another program through one standard interface. This page adds a new capability to the running DocsAssistant service: it becomes an MCP client of GitHub’s own MCP server and lets the model look up issues and pull requests of the documentation repositories, through the same tool-calling loop as its own searchDocs tool. The protocol itself, and the other side of the connection, are covered on Model Context Protocol; a Java client next to a Python and a TypeScript one is on Building MCP Clients, and the server side of DocsAssistant is on MCP Server. Every class on this page was compiled against the 2.0.1 jars. The client was also run against a local, token-protected MCP server standing in for GitHub’s (the hosted server itself needs an account and a token, so it was not called) and driven by a scripted ChatModel.

Protocol version

The MCP Java SDK that Spring AI 2.0.1 resolves (2.0.0) speaks the 2025-11-25 revision of the specification. The site’s MCP reference is written against 2026-07-28, so a Spring AI client connects with the earlier initialize handshake and cannot use what only the newer revision defines (server/discover, multi-round-trip elicitation). Servers that support both eras, as most hosted servers do during the transition, keep working. The details of that gap are in The protocol-version caveat.

What the starter builds

Adding the client starter and a few properties gives you, without any configuration class:

  • one MCP client per named connection in the properties, created and initialised at start-up;

  • a SyncMcpToolCallbackProvider bean that exposes the tools of every connection as Spring AI ToolCallback`s, so they are ordinary tools for `ChatClient;

  • a McpSyncClient bean per connection (McpAsyncClient if you choose the async type), for calls outside a chat;

  • annotation scanning that binds @Mcp…​ handler methods to connections by name;

  • customisation points (McpClientCustomizer, McpToolFilter, McpToolNamePrefixGenerator) that the auto-configuration picks up from the application context.

flowchart LR Q["User question"] --> CC["ChatClient with the tool-calling loop"] CC --> P["SyncMcpToolCallbackProvider"] P --> F["McpToolFilter: read-only tools only"] F --> C1["McpSyncClient: connection github"] F --> C2["McpSyncClient: any other connection"] C1 -->|"Streamable HTTP, token from the environment"| GH["GitHub MCP server"] C2 -->|"stdio"| PR["Local process"] GH -.->|"log, progress, tools changed"| H["Annotated handlers"] PR -.->|"log, progress, tools changed"| H
Figure 1. From a user question to a tool on another server

Starters and dependencies

There are two starters. spring-ai-starter-mcp-client uses the JDK HttpClient for the sse and streamable-http transports and also handles stdio. spring-ai-starter-mcp-client-webflux uses the Spring WebClient instead, so it shares its connection pool, codecs and instrumentation with the rest of a reactive application, and the reference recommends it for production HTTP connections. Both provide stdio. The client is either synchronous (the default) or asynchronous for the whole application; the two cannot be mixed.

pom.xml: the client starter next to the chat model of the running example
<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-client</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-ollama</artifactId>
    </dependency>
</dependencies>

The versions come from the spring-ai-bom import shown on Setup & Starters. Source: Spring AI: MCP Client Boot Starters.

Connections

Each transport has its own property subtree under spring.ai.mcp.client, and the key after connections. is the name that identifies the server in logs, customizers and handler annotations. Use a different name for every connection.

Table 1. The properties that matter, all verified against the starter’s configuration metadata
Property Default Meaning

enabled

true

Switches the whole client off

type

SYNC

SYNC or ASYNC for every client of the application

initialized

true

Initialise each client during start-up. With false the application starts even when a server is down, and the failure appears at the first use

request-timeout

20s

Timeout for requests to a server (a per-client value is set in a customizer)

root-change-notification

true

Notify servers when the roots change

toolcallback.enabled

true

Create the ToolCallbackProvider bean from the discovered tools

annotation-scanner.enabled

true

Scan beans for the client annotations

streamable-http.connections.<name>.url and .endpoint

endpoint /mcp

Base URL and path of a Streamable HTTP server

sse.connections.<name>.url and .sse-endpoint

sse-endpoint /sse

The older HTTP+SSE transport, for servers that have not moved to Streamable HTTP

stdio.connections.<name>.command, .args, .env

none

A local process that speaks MCP over standard input and output

stdio.servers-configuration

none

A JSON file in the Claude Desktop mcpServers format

Split a full URL into url (scheme, host and port) and the endpoint path. For the GitHub server that gives:

application.properties: GitHub’s hosted server over Streamable HTTP
spring.ai.mcp.client.type=SYNC
spring.ai.mcp.client.request-timeout=30s
spring.ai.mcp.client.streamable-http.connections.github.url=https://api.githubcopilot.com
spring.ai.mcp.client.streamable-http.connections.github.endpoint=/mcp/readonly

GitHub documents https://api.githubcopilot.com/mcp/ for the default toolset and the same URL with /readonly appended for a variant of it that offers read tools only. DocsAssistant answers questions and never writes to GitHub through this connection, so it asks for the read-only variant. That is the first of two layers: the client-side filter below is the second, and the token’s own permissions are the third. Source: GitHub MCP Server: remote server.

stdio and mcp-servers.json

A stdio connection starts a local process and talks to it over its standard streams. Either list it in the properties, or point stdio.servers-configuration at a JSON file in the format Claude Desktop uses, so a configuration can be shared between tools:

mcp-servers.json: a stdio server started in a container
{
  "mcpServers": {
    "github-local": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"]
    }
  }
}

The JSON above holds no secret: -e GITHUB_PERSONAL_ACCESS_TOKEN without a value makes Docker copy the variable from its own environment, which is the application’s, so it works only when the application was started with a variable of that name. To map another variable onto it, use the properties form.

application.properties: the same connection from properties, with the token mapped from another variable
spring.ai.mcp.client.stdio.servers-configuration=classpath:mcp-servers.json
# or, as a named connection of its own:
spring.ai.mcp.client.stdio.connections.github-local.command=docker
spring.ai.mcp.client.stdio.connections.github-local.args=run,-i,--rm,-e,GITHUB_PERSONAL_ACCESS_TOKEN,ghcr.io/github/github-mcp-server
spring.ai.mcp.client.stdio.connections.github-local.env.GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_MCP_TOKEN}

Three things about stdio were checked with a child process that dumped its environment:

  • Property placeholders are resolved in the properties form only. env.GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_MCP_TOKEN} reached the child as the value of the environment variable. In the JSON file the same ${…​} text arrived literally, so the JSON file cannot carry a secret by reference. Keep secrets out of it and use the properties form.

  • The child inherits the whole environment of the application. Every variable of the client process, including GITHUB_MCP_TOKEN itself and any other secret, was visible to the child. Starting the server in a container, as above, limits what it sees to the variables named with -e, which is a strong reason to prefer a container to a bare executable for third-party servers.

  • On Windows, npx, npm and similar commands are batch files, so they are started as cmd.exe with /c as the first argument. The reference explains the details.

A stdio server owns its standard output for the protocol, so any logging in the server must go to standard error (the rule from Transports).

The GitHub token

The GitHub server needs credentials, and the way they get into the request decides how safe the setup is. Two rules apply: the token comes from the environment or a secret store, never from a file in the repository, and it is sent to that one server and nowhere else.

The HTTP transports let you customise the outgoing request. A bean of type McpClientCustomizer for the transport builder is called once per connection with the connection’s name, so the token can be attached to github and to no other connection. A global McpSyncHttpClientRequestCustomizer bean, by contrast, is applied to every HTTP connection and would send GitHub’s token to whatever else you configure later.

DocsMcpClientConfig.java: the token for one connection, per-client timeouts and a read-only filter
package com.example.docsassistant;

import java.time.Duration;

import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import org.springframework.ai.mcp.McpToolFilter;
import org.springframework.ai.mcp.customizer.McpClientCustomizer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
class DocsMcpClientConfig {

    // Called once per streamable-http connection, with the connection's name from the properties.
    // The token is read from the environment and goes to the "github" connection only.
    @Bean
    McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> githubToken(
            @Value("${GITHUB_MCP_TOKEN}") String token) {
        return (connectionName, transport) -> {
            if ("github".equals(connectionName)) {
                transport.httpRequestCustomizer((request, method, uri, body, transportContext) ->
                        request.header("Authorization", "Bearer " + token));
            }
        };
    }

    // Called once per client, whatever its transport: timeouts, roots and the handlers of the spec
    @Bean
    McpClientCustomizer<McpClient.SyncSpec> clientTimeouts() {
        return (connectionName, spec) ->
                spec.requestTimeout(Duration.ofSeconds("github".equals(connectionName) ? 60 : 20));
    }

    // Deny by default: only tools that declare themselves read-only reach the model
    @Bean
    McpToolFilter readOnlyToolsOnly() {
        return (connection, tool) -> tool.annotations() != null
                && Boolean.TRUE.equals(tool.annotations().readOnlyHint());
    }
}

The value comes from ${GITHUB_MCP_TOKEN}, which Spring resolves from the environment of the process. Give the variable a fine-grained personal access token limited to read access on the repositories the assistant should see, and rotate it like any other credential. Do not log the token or the Authorization header, and do not add it to prompts. If the variable is missing the application fails at start-up with an unresolvable placeholder, which is the right failure. Run against the local stand-in, the token from GITHUB_MCP_TOKEN was accepted by the server for the github connection.

When the server uses OAuth instead of a token you manage yourself, use the community module mcp-client-security (org.springaicommunity:mcp-client-security, version 0.1.14 at the time of writing), whose customizers obtain and refresh OAuth 2.0 tokens through Spring Security’s client support. It supports the authorization-code, client-credentials and hybrid flows, and only McpSyncClient. The example below has the service call its own MCP server with a client-credentials token, again for one named connection only. Note that the reference page shows an older constructor for these classes; the signature below is the one in the 0.1.14 jar.

McpOAuthClientConfig.java: a client-credentials token for the "docs" connection
package com.example.docsassistant;

import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2ClientCredentialsSyncHttpRequestCustomizer;
import org.springframework.ai.mcp.customizer.McpClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;

/** DocsAssistant calling its own protected MCP server as a service: a client-credentials token per request. */
@Configuration(proxyBeanMethods = false)
@Profile("oauth")
class McpOAuthClientConfig {

    @Bean
    AuthorizedClientServiceOAuth2AuthorizedClientManager mcpAuthorizedClients(
            ClientRegistrationRepository registrations, OAuth2AuthorizedClientService authorizedClients) {
        var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, authorizedClients);
        manager.setAuthorizedClientProvider(OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build());
        return manager;
    }

    @Bean
    McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> docsServerToken(
            AuthorizedClientServiceOAuth2AuthorizedClientManager manager) {
        // "docs-mcp" is the name of the spring.security.oauth2.client.registration entry
        var customizer = new OAuth2ClientCredentialsSyncHttpRequestCustomizer(manager, "docs-mcp");
        return (connectionName, transport) -> {
            if ("docs".equals(connectionName)) {
                transport.httpRequestCustomizer(customizer);
            }
        };
    }
}

The client registration behind "docs-mcp" is ordinary Spring Security configuration (spring.security.oauth2.client.registration.docs-mcp., with the client secret taken from an environment variable). The token is issued *for the MCP server by its authorization server. Never forward the token of the user who called DocsAssistant to an MCP server: the specification calls that token passthrough and forbids it, because it lets a server act as the user elsewhere and defeats the audience check. The authorization flow, resource indicators and the server side are on Authorization and MCP Security, and the basics of OAuth under OAuth Reference. The community project is documented in Spring AI: MCP Security; it is marked as work in progress there, so check its repository for the Spring AI versions it supports.

Using the tools in a chat

The tools of all connections arrive as one SyncMcpToolCallbackProvider. Hand it to the ChatClient and the model can call them like searchDocs:

GithubIssueAssistant.java: MCP tools as default tools of a ChatClient
package com.example.docsassistant;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
import org.springframework.stereotype.Service;

@Service
class GithubIssueAssistant {

    private final ChatClient chatClient;

    // SyncMcpToolCallbackProvider is the bean the starter registers: the tools of every connection, after filtering
    GithubIssueAssistant(ChatClient.Builder builder, SyncMcpToolCallbackProvider mcpTools) {
        this.chatClient = builder
                .defaultSystem("""
                        You answer questions about the issues and pull requests of the documentation repositories.
                        Use the tools; if they return nothing useful, say so instead of guessing.
                        """)
                .defaultTools(mcpTools)
                .build();
    }

    String ask(String question) {
        return this.chatClient.prompt().user(question).call().content();
    }
}

Behind that one line, the flow is the tool-calling loop of Tool Calling: the model asks for a tool by name, the ToolCallingAdvisor runs the callback, the callback sends tools/call to the server, and the result goes back to the model. Everything said there applies: the model chooses the arguments, so the input is untrusted; spring.ai.tools.limits.* bounds the number of calls; and the loop, ordering and limits are on Tool Calling Advisor & Tool Search. The result of an MCP tool call reached the model as JSON text (in the run, a server text result 1 arrived as [{"text":"1"}]), so the model reads the server’s content blocks, not a Java object.

Servers with many tools change the economics: GitHub’s server offers dozens, and every definition is sent to the model on every request. Filter the tools first, as below, and if a large set is unavoidable use the tool search advisor from the page just mentioned.

Filtering and naming

Tools from a server you do not control are input, like anything else that reaches the model, so decide what the model may use. A single McpToolFilter bean receives the connection and the tool and answers yes or no. The one in DocsMcpClientConfig admits only tools that declare themselves read-only through the standard tool annotations. Those annotations are hints, not enforcement (a dishonest server can lie), which is why the token’s permissions and the read-only URL remain necessary. Run against a server with three tools, the model was offered only the one with readOnlyHint set, while an injected McpSyncClient still listed all three, because the filter applies to the ToolCallbackProvider only. Only one McpToolFilter bean may exist; combine rules inside it.

Tool names must be unique across connections. By default a DefaultMcpToolNamePrefixGenerator keeps them apart: it replaces non-alphanumeric characters with underscores, leaves the first occurrence of a name alone, gives later duplicates a counter prefix such as alt_1_search and keeps the result within 64 characters. Provide your own McpToolNamePrefixGenerator bean to prefix every tool with its server, or McpToolNamePrefixGenerator.noPrefix() when you know there are no clashes (a clash then fails with an IllegalStateException).

A ToolContext passed to a request is converted into the _meta of the MCP call by a ToolContextToMcpMetaConverter bean. The default drops the MCP exchange entry and null values and passes everything else, so do not put anything in the tool context that a remote server should not see; register ToolContextToMcpMetaConverter.noOp() to send nothing.

Client customizers

McpClientCustomizer<B> is the extension point of the starter. It has one method, customize(String connectionName, B builder), and Spring picks up every bean of the type it can customise:

  • McpClientCustomizer<McpClient.SyncSpec> (or AsyncSpec) configures a client: request timeout, roots, and the handlers of the specification (sampling, elicitation, progress, logging and the three list-changed notifications);

  • McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> configures a Streamable HTTP transport, which is where headers and authorisation go.

DocsMcpClientConfig uses both. The annotation model below is the declarative alternative to the handler methods of SyncSpec, and it is the one to prefer: the annotation scanner registers the handlers on the right client for you.

Client annotations

A server may send messages that nobody asked for: log lines, progress updates, a changed tool list, and requests that need the client to act, such as a question for the user. The client annotations bind Java methods to those messages. Every annotation requires a clients value naming the connections it serves, exactly as spelled in the properties.

Table 2. The client annotations
Annotation Handles

@McpLogging

Log message notifications from the server. The method receives the notification or its level, logger and data

@McpProgress

Progress notifications of a long-running request; the token is the progressToken that the request carried

@McpToolListChanged, @McpResourceListChanged, @McpPromptListChanged

The server’s tool, resource or prompt list changed. The tool callback provider refreshes itself, so this is for your own bookkeeping

@McpElicitation

A request for the user to provide information. Return an ElicitResult with the action ACCEPT, DECLINE or CANCEL

@McpSampling

A request for the client’s model to produce a completion on the server’s behalf. Return a CreateMessageResult

McpClientHandlers.java: logging, progress, list changes and a declining elicitation handler
package com.example.docsassistant;

import java.util.List;

import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.spec.McpSchema.ProgressNotification;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.mcp.annotation.McpElicitation;
import org.springframework.ai.mcp.annotation.McpLogging;
import org.springframework.ai.mcp.annotation.McpProgress;
import org.springframework.ai.mcp.annotation.McpToolListChanged;
import org.springframework.stereotype.Component;

/** Handlers for what a server sends on its own. "github" is the connection name from the properties. */
@Component
class McpClientHandlers {

    private static final Logger log = LoggerFactory.getLogger(McpClientHandlers.class);

    @McpLogging(clients = "github")
    void onLog(LoggingMessageNotification notification) {
        // server-controlled text: log the level and the logger, and keep the message out of prompts
        log.info("MCP server log [{}] from {}", notification.level(), notification.logger());
    }

    @McpProgress(clients = "github")
    void onProgress(ProgressNotification notification) {
        Double total = notification.total();
        log.info("MCP progress {} of {}", notification.progress(), total == null ? "?" : total);
    }

    @McpToolListChanged(clients = "github")
    void onToolsChanged(List<McpSchema.Tool> tools) {
        log.info("The GitHub server now offers {} tools", tools.size());   // the tool callback provider refreshes itself
    }

    // A headless service has nobody to ask, so every elicitation is declined
    @McpElicitation(clients = "github")
    ElicitResult onElicit(ElicitRequest request) {
        log.warn("Declining an elicitation request from the GitHub server");
        return new ElicitResult(ElicitResult.Action.DECLINE, null);
    }
}

Handlers with a Mono return type belong to ASYNC clients, the others to SYNC ones, and methods of the wrong kind are ignored. Four points of judgement matter more than the syntax:

  • Server text is untrusted. A log message comes from someone else’s program. The handler logs the level and the logger and leaves the message text out of prompts and of anything a model reads.

  • Elicitation needs a person. DocsAssistant is a headless service, so it declines. A client with a user interface shows the request to the user and never fills in answers itself, and never accepts a request that asks for a password or other secret. In 2.0 the SDK splits the request type: the handler receives the ElicitRequest interface, whose form variant is ElicitFormRequest (with requestedSchema()); the older reference examples that call requestedSchema() on the interface do not compile.

  • Sampling spends your money. A @McpSampling handler lets a server run prompts on your model. Enable it only for servers you trust, cap the size and number of requests, and treat the prompt as untrusted input.

  • Progress is opt-in per request. A server reports progress only for a request that carried a progress token, which the tool-context conversion above can provide.

In the run, calling open_issue on the local stand-in made the server send a log notification, and the @McpLogging handler for the github connection logged its level and logger.

Sync, async and direct client access

McpConnectionInventory shows the second way in: the starter also registers each connection as a McpSyncClient bean, which you can inject as a list.

McpConnectionInventory.java: calling the clients directly
package com.example.docsassistant;

import java.util.List;

import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.springframework.stereotype.Component;

/** The starter also exposes each connection as an McpSyncClient bean, for calls outside a chat. */
@Component
class McpConnectionInventory {

    private final List<McpSyncClient> clients;

    McpConnectionInventory(List<McpSyncClient> clients) {
        this.clients = clients;
    }

    List<String> toolNames() {
        return this.clients.stream()
                .flatMap(client -> client.listTools().tools().stream())
                .map(Tool::name)
                .sorted()
                .toList();
    }
}

Use the client directly for things a chat does not do: listing what a server offers at start-up for a health check, reading a resource, or calling a tool from a scheduled job without a model. Set spring.ai.mcp.client.type=ASYNC to get McpAsyncClient beans and an AsyncMcpToolCallbackProvider for a fully reactive application, with the reactive handlers described above; see Streaming & Reactive for how the reactive and blocking worlds meet.

Failure at start-up

Clients are initialised while the application starts, and an unreachable server is a start-up failure. Run against a closed port, the application did not start: the mcpToolCallbacks bean could not be created. With spring.ai.mcp.client.initialized=false the same application started normally, and the first attempt to list tools failed with Client failed to initialize listing tools. Choose deliberately. Fail-fast suits a service that is useless without the server. An optional integration, such as GitHub lookups in a documentation assistant, should start without it, catch the failure where the tools are used, and answer without them.

Table 3. Problems with an MCP client and where to look
Symptom Likely cause Fix

401 from the server during start-up

Missing or wrong token, or a token for another audience

Check the variable is set for the process, and that the customizer names the right connection

The model never sees a tool the server offers

McpToolFilter rejected it, the annotation scanner or toolcallback.enabled is off, or the wrong client type is set

Log the tool names from the injected clients and compare them with what the filter admits

IllegalStateException about duplicate tool names

noPrefix() with two servers offering the same tool

Use the default prefix generator or write a prefixing one

A handler never runs

clients does not match the connection name, or a sync handler was declared on an async client

Copy the name from the properties; match the handler kind to type

A secret in mcp-servers.json did not resolve

The JSON file does not expand placeholders

Use the properties form for stdio connections that need secrets

Testing

A client is tested against a server. The cheapest reliable one is your own: start a Spring Boot application that serves a few @McpTool methods (see MCP Server, whose test class starts a real server on a random port) and point the connection at it in the test. The scripted ChatModel from Tool Calling then asks for one of its tools, and the assertion is on the model’s second prompt, which must contain the tool result. Use the MCP Inspector (Testing & Debugging) when you want to see the traffic of a server independently of your client.