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.
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
SyncMcpToolCallbackProviderbean that exposes the tools of every connection as Spring AIToolCallback`s, so they are ordinary tools for `ChatClient; -
a
McpSyncClientbean per connection (McpAsyncClientif 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.
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.
<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.
| Property | Default | Meaning |
|---|---|---|
|
|
Switches the whole client off |
|
|
|
|
|
Initialise each client during start-up. With |
|
|
Timeout for requests to a server (a per-client value is set in a customizer) |
|
|
Notify servers when the roots change |
|
|
Create the |
|
|
Scan beans for the client annotations |
|
endpoint |
Base URL and path of a Streamable HTTP server |
|
sse-endpoint |
The older HTTP+SSE transport, for servers that have not moved to Streamable HTTP |
|
none |
A local process that speaks MCP over standard input and output |
|
none |
A JSON file in the Claude Desktop |
Split a full URL into url (scheme, host and port) and the endpoint path. For the GitHub server that gives:
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:
{
"mcpServers": {
"github-local": {
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"]
}
}
}
Source: Spring AI: MCP Client Boot Starters.
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.
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}
Source: Spring AI: MCP Client Boot Starters.
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_TOKENitself 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,npmand similar commands are batch files, so they are started ascmd.exewith/cas 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.
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());
}
}
Source: Spring AI: MCP Client Boot Starters.
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.
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);
}
};
}
}
Source: Spring AI: MCP Security.
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:
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();
}
}
Source: Spring AI: MCP Client Boot Starters.
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>(orAsyncSpec) 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.
| Annotation | Handles |
|---|---|
|
Log message notifications from the server. The method receives the notification or its level, logger and data |
|
Progress notifications of a long-running request; the token is the |
|
The server’s tool, resource or prompt list changed. The tool callback provider refreshes itself, so this is for your own bookkeeping |
|
A request for the user to provide information. Return an |
|
A request for the client’s model to produce a completion on the server’s behalf. Return a |
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);
}
}
Source: Spring AI: MCP Client Annotations.
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
ElicitRequestinterface, whose form variant isElicitFormRequest(withrequestedSchema()); the older reference examples that callrequestedSchema()on the interface do not compile. -
Sampling spends your money. A
@McpSamplinghandler 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.
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();
}
}
Source: Spring AI: MCP Client Boot Starters.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
|
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 |
|
Log the tool names from the injected clients and compare them with what the filter admits |
|
|
Use the default prefix generator or write a prefixing one |
A handler never runs |
|
Copy the name from the properties; match the handler kind to |
A secret in |
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.