Skip to main content

Build an MCP Client with Spring AI

The other half of building an MCP server is consuming one. This article covers connecting a Spring AI application to one or more MCP servers, then handing the tools those servers expose to a ChatClient.

Add the starter

Standard client, covers STDIO and HTTP-based transports
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>

There's also spring-ai-starter-mcp-client-webflux for a reactive client, needed if the rest of the application is built on WebFlux.

Configure the servers to connect to

Server connections are declared in configuration, not code. The client can hold connections to several servers at once, each identified by a name used only within this configuration.

application.yaml
spring:
ai:
mcp:
client:
name: my-mcp-client
version: 1.0.0
type: SYNC
request-timeout: 30s
stdio:
connections:
filesystem:
command: npx
args:
- -y
- "@modelcontextprotocol/server-filesystem"
- /tmp
streamable-http:
connections:
calculator:
url: http://localhost:8080
endpoint: /mcp

This connects to two servers: a filesystem server launched over STDIO, and the calculator server built in the previous article over Streamable HTTP. type: SYNC is the default and is enough for a typical request/response application; switch to ASYNC if the rest of the application is reactive.

If a set of STDIO servers is already described in the Claude Desktop-style JSON format used elsewhere in this section (see MCP Gateway), that file can be reused directly instead of listing connections inline:

spring:
ai:
mcp:
client:
stdio:
servers-configuration: classpath:mcp-servers.json

Hand the tools to a ChatClient

Once the starter is on the classpath and at least one server is configured, Spring AI auto-configures a SyncMcpToolCallbackProvider (or the async equivalent) that aggregates tools from every connected server. Pass it straight to .tools():

McpChatbotService
package com.example.mcp.client;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.mcp.client.tool.SyncMcpToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class McpChatbotService {

@Autowired
private ChatModel chatModel;

@Autowired
private SyncMcpToolCallbackProvider toolCallbackProvider;

public String chat(String userMessage) {
return ChatClient.create(chatModel)
.prompt(userMessage)
.tools(toolCallbackProvider.getToolCallbacks())
.call()
.content();
}
}

From here, tool selection works exactly like the @Tool-based tools described in Tools: the model sees the name, description, and schema of every tool from every connected server, and picks which ones to call based on the conversation. The application doesn't need to know or care whether a given tool came from a local filesystem server or a remote calculator server.

If direct access to a server is needed instead, for example to call listTools() or invoke something outside the normal tool-calling flow, inject List<McpSyncClient> (or List<McpAsyncClient>) and each configured connection shows up as one entry.

A few things worth knowing before connecting to someone else's server

Every tool from every connected server ends up in the same flat list handed to the model. With a handful of servers this is fine; with a dozen, the model has to pick the right tool out of a large list every time, which tends to hurt tool selection accuracy. This is the same problem the MCP Gateway article covers from the server side, and it applies here too: fewer, more targeted server connections per ChatClient beat one client wired to everything available.

There's also no built-in trust boundary between the client and a server it connects to. A malicious or compromised MCP server can return misleading tool descriptions or results, which the model then treats as ground truth. Only connect to MCP servers you trust, the same way you'd think twice before running a script from an unfamiliar source.