Module 5: Multi-Server Agents
Duration: ~40 minutes | Level: Intermediate | Prerequisites: Module 4 complete, working ChatClient agent with filesystem tools
What You'll Build
An agent connected to two MCP servers at once:
- A filesystem server for file operations
- A custom MCP server (the one you built in the Java SDK course, or any other server you have) for business data
The agent transparently uses tools from either server as needed, without any routing logic in your application code.
Why Multi-Server?
Real agents rarely do just one thing. A developer productivity agent might need:
- Filesystem access (read and modify code)
- Git operations (commits, branches, diffs)
- Issue tracker access (create tickets, check status)
- CI/CD pipeline access (trigger builds, check results)
Each of these capabilities comes from a different MCP server. The standard protocol means all of them integrate identically, your application doesn't care which server a tool comes from.
The alternative without MCP would be custom integration with every API: different auth, different request formats, different error handling, different tool definition syntax. You'd write four integrations instead of four configuration lines.
This is the N+M vs N×M argument from the Fundamentals course, applied to your own application.
Two-Server Configuration
For this module, we'll connect to:
- The official filesystem server (from previous modules)
- A simple weather server, a minimal MCP server that returns mock weather data, which we'll define shortly
The weather server below is a self-contained illustration so this module stands on its own: it needs no other module and demonstrates the multi-server pattern with two stdio connections. The companion reference's multi-server module takes a different (and slightly more advanced) route: instead of a weather server, it connects to the order management server you build in Module 6 over Streamable HTTP (the Spring AI 2.0 default transport), alongside the filesystem server over stdio. That is a forward reference, the order server does not exist yet at this point in the course, so the lesson uses the weather server here and the reference shows the order-server wiring once both modules are present. The agent code and configuration shape are identical; only the second server differs.
In application.yml (switching from .properties for cleaner multi-line list syntax):
spring:
ai:
anthropic:
api-key: ${ANTHROPIC_API_KEY}
chat:
model: claude-sonnet-4-6
mcp:
client:
# Global request timeout for all MCP operations
# Spring AI 2.0.x has no per-connection timeout properties; use this single knob.
request-timeout: 30s
toolcallback:
enabled: true
stdio:
connections:
filesystem:
command: npx
args:
- -y
- "@modelcontextprotocol/server-filesystem"
- /tmp
weather:
command: java
args:
- -jar
- ${user.home}/mcp-servers/weather-server.jar
main:
banner-mode: off
The args field is a YAML list here, cleaner than comma-separated strings in .properties when arguments include special characters or spaces.
If you built the MCP server in the Java SDK course, you can use it here. Replace the weather connection above with your server's command. The agent will automatically discover and use its tools alongside the filesystem tools.
Build a Minimal Weather MCP Server (For This Module)
If you don't have a second MCP server to connect to, here's a minimal one. This is a self-contained Spring Boot application using the Java MCP SDK that you can build and run as a separate process:
<!-- weather-server/pom.xml, standalone module -->
<dependencies>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>2.0.0</version>
</dependency>
<!--
The bundled `mcp` artifact ships the Jackson 3 mapper (`mcp-json-jackson3`) by default;
MCP SDK 2.0 also provides `mcp-json-jackson2` for projects staying on Jackson 2.
The sample below uses the Jackson 2 mapper (`com.fasterxml.jackson.databind.ObjectMapper`),
so we add the Jackson 2 module explicitly:
-->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-json-jackson2</artifactId>
<version>2.0.0</version>
</dependency>
<!-- Logback to stderr (stdout is reserved for MCP protocol) -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
</dependencies>
package com.themcpguy.weather;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import java.util.List;
import java.util.Map;
import java.util.Random;
public class WeatherServer {
public static void main(String[] args) throws InterruptedException {
McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transportProvider = new StdioServerTransportProvider(jsonMapper);
Tool weatherTool = Tool.builder()
.name("get_current_weather")
.description("Get the current weather conditions for a city")
// MCP SDK 2.0 added an inputSchema(Map) overload (and Tool.builder(name, Map)),
// so you can now pass the schema as a Map. Here we keep the JSON-string form via
// the McpJsonMapper overload, which is still supported and reads cleanly inline.
.inputSchema(jsonMapper, """
{
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name (e.g., 'London', 'Tokyo')"
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature units",
"default": "celsius"
}
},
"required": ["city"]
}
""")
.build();
var weatherSpec = McpServerFeatures.SyncToolSpecification.builder()
.tool(weatherTool)
.callHandler((exchange, request) -> {
String city = (String) request.arguments().get("city");
String units = (String) request.arguments().getOrDefault("units", "celsius");
// Mock data; replace with a real weather API call in production
int tempC = 15 + new Random().nextInt(20);
String temp = units.equals("fahrenheit")
? (tempC * 9 / 5 + 32) + "°F"
: tempC + "°C";
String report = """
Weather in %s:
Temperature: %s
Conditions: Partly cloudy
Humidity: 65%%
Wind: 12 km/h NW
""".formatted(city, temp);
return CallToolResult.builder()
.addTextContent(report)
.build();
})
.build();
McpServer.sync(transportProvider)
.serverInfo("weather-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.tools(weatherSpec)
.build();
// Sync server is driven by the transport; just block the main thread.
Thread.currentThread().join();
}
}
Build it as a fat JAR and place it at ~/mcp-servers/weather-server.jar. Spring AI will start it as a child process when the main application starts.
The Multi-Server Agent
The agent code barely changes from Module 4; that's the point.
package com.themcpguy.springai.multi;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
import org.springframework.stereotype.Service;
@Service
public class MultiServerAgentService {
private final ChatClient chatClient;
MultiServerAgentService(ChatClient.Builder builder,
SyncMcpToolCallbackProvider mcpTools) {
this.chatClient = builder
.defaultSystem("""
You are a productive assistant with access to:
1. Filesystem tools, read, write, and explore files in /tmp
2. Weather tools, current weather for any city
Use the appropriate tools based on what the user asks.
When using multiple tools to answer a question, do so efficiently.
""")
// defaultTools(Object...) accepts a ToolCallback, a ToolCallbackProvider
// (this MCP one, resolved lazily at request time), arrays or collections of
// either, or @Tool POJOs. It replaces the deprecated defaultToolCallbacks(...).
.defaultTools(mcpTools)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder().build()).build())
.build();
}
public String chat(String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
}
SyncMcpToolCallbackProvider aggregates tools from all configured MCP connections automatically. You don't enumerate server names or specify routing, the provider returns all tools from all servers, and the model decides which to use.
Try these prompts:
You: What's the weather in Tokyo right now?
Agent: [uses get_current_weather from the weather server]
You: Save a weather report for Tokyo and London to /tmp/weather-report.txt
Agent: [calls get_current_weather twice, then write_file, using tools from two servers]
You: Read back the weather report
Agent: [uses read_text_file from the filesystem server]
The second prompt uses tools from two different servers in a single response. The model selects them based on their descriptions. Your application code handles none of that routing.
How Tool Disambiguation Works
When multiple MCP servers are connected, the model receives tool definitions from all of them. The model uses each tool's name and description to decide which to call.
In Spring AI 2.0.x the auto-configured SyncMcpToolCallbackProvider uses DefaultMcpToolNamePrefixGenerator by default, which does NOT prefix tool names with the client or connection name: a read_text_file tool on the filesystem connection is exposed to the model as just read_text_file (the name is passed through McpToolUtils.format, which only strips characters outside [a-zA-Z0-9_-] and converts hyphens to underscores). If two connected servers expose the same tool name, the default generator auto-disambiguates the duplicate with an alt_<n>_ prefix (for example alt_1_read_text_file) rather than throwing. You can change this by supplying your own McpToolNamePrefixGenerator bean: one built on McpToolUtils.prefixedToolName to prepend the connection name, or McpToolNamePrefixGenerator.noPrefix() to disable de-duplication, in which case a genuine duplicate surfaces as an IllegalStateException from getToolCallbacks().
You can inspect which tools are visible to the model:
@Autowired
private SyncMcpToolCallbackProvider mcpTools;
public void printAllTools() {
for (var tool : mcpTools.getToolCallbacks()) {
System.out.printf("[%s] %s%n",
tool.getToolDefinition().name(),
tool.getToolDefinition().description().substring(0, 60));
}
}
This is also a useful diagnostic when the model isn't using a tool you expect, verify it's actually in the tool list.
Selective Tool Registration
By default, SyncMcpToolCallbackProvider gives the model all tools from all servers. For some agents, you want fine-grained control, expose only a subset of tools, or expose different tools for different users.
You can filter tool callbacks and pass them explicitly:
import java.util.Arrays;
import java.util.List;
import org.springframework.ai.tool.ToolCallback;
// Only expose filesystem read tools (no write access)
ToolCallback[] readOnlyTools = Arrays.stream(mcpTools.getToolCallbacks())
.filter(t -> t.getToolDefinition().name().startsWith("read_"))
.toArray(ToolCallback[]::new);
String response = chatClient.prompt()
.user(userMessage)
.tools(List.of(readOnlyTools)) // per-request tool specification
.call()
.content();
Or build separate ChatClient instances for different roles:
@Bean
public ChatClient readOnlyAgent(ChatClient.Builder builder,
SyncMcpToolCallbackProvider allTools) {
ToolCallback[] readTools = Arrays.stream(allTools.getToolCallbacks())
.filter(t -> t.getToolDefinition().name().matches("read_.*|list_.*|get_.*"))
.toArray(ToolCallback[]::new);
return builder
.defaultSystem("You are a read-only assistant. You can inspect files but not modify them.")
.defaultTools(readTools)
.build();
}
@Bean
public ChatClient adminAgent(ChatClient.Builder builder,
SyncMcpToolCallbackProvider allTools) {
return builder
.defaultSystem("You are an admin assistant with full filesystem access.")
.defaultTools(allTools) // pass the provider; all tools registered
.build();
}
Qualify which ChatClient to inject with @Qualifier("readOnlyAgent") or @Qualifier("adminAgent"). This gives you role-based tool access without any additional framework support.
Connection Failure Handling
What happens if one MCP server fails to start? By default, Spring AI fails fast: if any configured MCP connection can't be established during startup, the application fails to start.
Spring AI offers a global graceful-degradation toggle (a fail-fast/lazy-connection option), introduced via issue spring-projects/spring-ai#3232 (closed, first shipped in milestone 1.1.0.M4 and carried into 2.0): you can switch the whole client off fail-fast so an unreachable server no longer blocks startup. What Spring AI 2.0.x still does not expose is a per-connection "required" flag, so you cannot mark one specific connection as optional while keeping the rest mandatory. When you need that per-connection control, the workarounds today are:
- Spring profiles. Move optional connections into profile-specific configuration files (
application-with-optional.yml) and only activate the profile when the optional server is reachable. CI/CD can probe the dependency first and inject the right profile. - Externalise the config. Read the connection list from environment variables or a config server; omit missing servers from the resolved configuration entirely.
- Custom autoconfiguration. Implement your own
McpClientbean that wrapsSyncMcpToolCallbackProviderand catches connection failures, surfacing missing tools as a degraded state.
You can check which tools are actually available at runtime via SyncMcpToolCallbackProvider.getToolCallbacks() and use that to vary agent behaviour.
Parallelising Tool Calls
When the model decides to call multiple tools, Spring AI's current MCP client invokes them sequentially. Models like Claude can return multiple tool_use blocks in a single response (signalling that they're independent), but as of Spring AI 2.0.x the MCP client does not expose a configuration flag to execute these in parallel. Issue spring-projects/spring-ai#5195 (still open) tracks this request.
If parallelism matters for your workload today, the options are:
- Aggregate at the tool level. Design a single MCP tool that internally fans out (e.g.,
get_weather_batch(cities)) so one tool call replaces N calls. - Custom
ToolCallbackwrapper. Wrap each MCPToolCallbackin your own implementation that dispatches to a thread pool. This requires care for thread-safety on shared MCP connections. - Track Spring AI releases. Parallel MCP tool execution is on the roadmap; check the changelog when upgrading.
Some models (notably Claude) signal parallel tool calls explicitly, they return multiple tool_use blocks in a single response. Sequential execution still works correctly; you just pay the round-trip latency per call.
Performance Considerations
Each additional MCP server adds latency to startup (the connection + tools/list handshake) and token usage (tool definitions are sent to the model on every request, consuming input tokens).
Rough estimates:
- Each MCP connection at startup: 100-500ms for stdio (process start)
- Token cost of tool definitions: ~50-200 tokens per tool
- 10 tools ≈ 1000-2000 extra input tokens per request
For agents with many tools, this matters. Strategies to mitigate:
- Tool filtering: expose only relevant tools per request (shown above)
- Dynamic tool registration: pass tools per-request based on context
- Connection pooling: for SSE connections, reuse persistent connections
The right strategy depends on your tool count and request volume.
Next: Module 6: Spring as an MCP Server. Flip the relationship: expose your Spring beans as MCP tools that any MCP client can use.