Skip to main content

Class 10: Several Servers at Once

Duration: ~30 minutes | Level: Intermediate | Prerequisites: Class 9: A Server We Did Not Write.


What We'll Cover

  • A third connection that offers the same tool names as the second
  • What Spring AI does about it before we do anything
  • McpToolNamePrefixGenerator, for naming that means something
  • McpToolFilter, for keeping tools away from an agent that should not have them
  • What happens at startup when one server is unavailable
  • What connecting servers costs

A Third Connection

The support team keeps its old notes. When a policy changed, the previous version went into an archive, and an agent handling a two-year-old order sometimes needs what was true then.

The starter has that directory too:

support-kb-archive/
├── escalation.md the pre-2024 escalation ladder
├── carriers.md carriers we no longer use
└── refunds-process.md the process before the refund system changed

Serve it with a second filesystem server:

        stdio:
connections:
knowledge-base:
command: npx
args:
- -y
- "@modelcontextprotocol/server-filesystem"
- ./support-kb
knowledge-base-archive:
command: npx
args:
- -y
- "@modelcontextprotocol/server-filesystem"
- ./support-kb-archive

Two connections, two child processes, two sandboxes. And two servers offering read_text_file, list_directory, search_files and everything else, under exactly the same names.

This is the awkward case, and it is better to meet it here, with two directories we control, than in a running system.


What Happens Without Us

Restart the agent and read the log:

Tool name 'read_text_file' already exists. Using unique tool name 'alt_1_read_text_file'
Tool name 'write_file' already exists. Using unique tool name 'alt_2_write_file'
Tool name 'list_directory' already exists. Using unique tool name 'alt_3_list_directory'
...

Spring AI has a McpToolNamePrefixGenerator bean, and DefaultMcpToolNamePrefixGenerator is active unless we replace it. When a name it has already handed out comes round again, it prefixes the second one with alt_, a counter that starts at 1, and an underscore. It logs each rename at WARN.

So the collision does not reach the model as a collision. It reaches the model as a tool called alt_1_read_text_file, which is unique and meaningless. The model has to work out from the description that this one reads the archive, and the description came from the filesystem server, which does not know it is serving an archive.

That is the real problem. Not that the names clash, but that the automatic fix produces names carrying no information, and the counter's order depends on which connection was processed first.


Naming Them Ourselves

Replace the bean:

package com.themcpguy.supportdesk.agent;

import io.modelcontextprotocol.spec.McpSchema.Tool;

import org.springframework.ai.mcp.McpConnectionInfo;
import org.springframework.ai.mcp.McpToolNamePrefixGenerator;
import org.springframework.stereotype.Component;

@Component
public class ServerNamePrefixGenerator implements McpToolNamePrefixGenerator {

@Override
public String prefixedToolName(McpConnectionInfo connectionInfo, Tool tool) {
String serverName = connectionInfo.initializeResult().serverInfo().name();
return switch (serverName) {
case "order-service" -> tool.name();
default -> serverName.replace('-', '_') + "_" + tool.name();
};
}
}

Now the tools are named after where they came from. Our own tools keep their names, and the two filesystem servers get a prefix each.

The switch above is on serverInfo().name(), and for the two filesystem servers that does not work. Both report the same serverInfo, because they are the same program started twice. Telling them apart means using connectionInfo.clientInfo(), because with two copies of one npm package the server itself has nothing that distinguishes them.

The route that does work is to use the connection name, which is ours, rather than the server name, which is the server's. It arrives on clientInfo(), and it needs a little care:

@Override
public String prefixedToolName(McpConnectionInfo connectionInfo, Tool tool) {
String serverName = connectionInfo.initializeResult() != null
? connectionInfo.initializeResult().serverInfo().name()
: null;

if ("order-service".equals(serverName)) {
return tool.name();
}
return connectionName(connectionInfo) + "_" + tool.name();
}

private static String connectionName(McpConnectionInfo connectionInfo) {
// "support-agent - knowledge-base-archive": the client name and the connection
// name, with a separator. Only the second half identifies the connection.
String raw = connectionInfo.clientInfo().name();
int separator = raw.lastIndexOf(" - ");
String connection = separator < 0 ? raw : raw.substring(separator + 3);

return connection.trim().toLowerCase().replaceAll("[^a-z0-9]+", "_");
}

clientInfo().name() is not the connection name on its own. It is our client's name, then " - ", then the connection name from application.yaml. Using it whole gives a tool called support-agent - knowledge-base-archive_read_text_file, and a tool name containing spaces is not something a model handles well. Taking the part after the separator and reducing it to letters, digits and underscores gives knowledge_base_archive_read_text_file.

McpToolNamePrefixGenerator also offers McpToolNamePrefixGenerator.noPrefix(), which returns the tool name unchanged. It is available and it is a trap with more than one server: two tools with the same name and no prefix means one of them is unreachable.

Telling the model which is which

Renaming makes the tools distinguishable. It does not tell the model when to use the archive. That belongs in the system prompt, because it is a decision about our domain:

The knowledge base has two sources. Use knowledge_base_* tools for anything
current. Use knowledge_base_archive_* tools only when the user asks about how
something worked in the past, or when an order predates 2024. If the two
disagree, the current one is right and say so.

Keeping Tools Away From an Agent

Twenty-eight filesystem tools include write_file, move_file and create_directory. A support agent answering questions has no business with any of them, and every one of them is sent to the model on every request.

McpToolFilter is a BiPredicate<McpConnectionInfo, Tool> that decides what gets through:

package com.themcpguy.supportdesk.agent;

import java.util.Set;

import io.modelcontextprotocol.spec.McpSchema.Tool;

import org.springframework.ai.mcp.McpConnectionInfo;
import org.springframework.ai.mcp.McpToolFilter;
import org.springframework.stereotype.Component;

@Component
public class ReadOnlyKnowledgeBaseFilter implements McpToolFilter {

private static final Set<String> ALLOWED_FILE_TOOLS =
Set.of("read_text_file", "list_directory", "search_files", "directory_tree");

@Override
public boolean test(McpConnectionInfo connectionInfo, Tool tool) {
if ("order-service".equals(connectionInfo.initializeResult().serverInfo().name())) {
return true;
}
return ALLOWED_FILE_TOOLS.contains(tool.name());
}
}

Four tools instead of fourteen, per connection. The model has less to read, the request is smaller, and nothing it can call writes anything.

A filter is not a security boundary. It keeps tools out of the model's sight. It does not stop anything else in the application from calling them, and it does not stop the server from being able to do them. An agent that must not write should be connected to a server that cannot write. For the filesystem server, that means a directory it has read-only access to at the operating-system level.


When a Server Is Not There

Stop order-service and start the agent.

By default Spring AI fails fast: a configured connection that cannot be established at startup stops the application. With one server that is reasonable. With three, any one being down takes everything with it.

Spring AI 2.0 has a global switch so an unreachable server no longer blocks startup (spring-ai#3232, shipped in 1.1.0.M4 and carried into 2.0). What it does not offer is a per-connection "optional" flag, so one connection cannot be optional while the rest stay mandatory.

Until it does, the practical routes are:

  1. Profiles. Keep optional connections in profile-specific configuration and activate the profile when that server is known to be up.
  2. Externalised configuration. Build the connection list from the environment, leaving out what is unavailable.
  3. Adapt at runtime. getToolCallbacks() reports what actually arrived, so the agent can adjust its system prompt to the tools it really has.

The third is the most robust and the least common. An agent that says "I cannot look up orders at the moment" is more useful than one that fails to start.


What This Costs

Startup. Every connection is a process to start or a socket to open, and a handshake to complete, before the application is ready.

Tokens, on every request. All the tool definitions are sent with each question, and again on each turn of the tool loop. Connecting a server "just in case" is paid for on every request rather than once. This is the strongest argument for the filter above.

Tool calls run one after another. When a model asks for several tools at once (Claude signals this by returning several tool_use blocks), Spring AI 2.0.x executes them sequentially, and there is no flag to parallelise (spring-ai#5195 is open). Where that latency matters, the usual answer is a server-side tool that does the fan-out in one call. Class 11 builds one.


Check It

Start order-service in one terminal and the agent in another:

You run this, in one terminal
mvn -pl order-service spring-boot:run
You run this, in another
mvn -pl support-agent spring-boot:run

The inspector from Class 6 reports each connection, and then what the model is actually handed after the filter and the prefix generator have run:

Connected to secure-filesystem-server 0.2.0
tool read_file
tool read_text_file
... fourteen in total
Connected to secure-filesystem-server 0.2.0
... the same fourteen
Connected to order-service 1.0.0
tool cancel_order
tool get_customer_orders
tool get_order
tool get_orders_by_status
tool recheck_shipments
tool update_order_status
resource policy://shipping
resource policy://returns
template order://{orderId}
prompt draft_refund_email
The model is given 16 tools:
knowledge_base_archive_read_file
knowledge_base_archive_read_text_file
knowledge_base_archive_list_directory
knowledge_base_archive_directory_tree
knowledge_base_archive_search_files
knowledge_base_read_file
knowledge_base_read_text_file
knowledge_base_list_directory
knowledge_base_directory_tree
knowledge_base_search_files
cancel_order
get_customer_orders
get_order
get_orders_by_status
recheck_shipments
update_order_status

Read the two lists against each other. Each filesystem server offers fourteen tools and the model is given five of them, because the filter dropped everything that writes. Our own six keep their names, and the two knowledge bases are now told apart by their prefixes.

Sixteen instead of thirty-four, and every one of them says where it came from.


What We Built

Three connections over two transports, with names that say where each tool came from and a filter that keeps the write tools out. We saw what Spring AI does about a collision on its own, and why the automatic answer is not good enough to leave alone.

Everything so far has been the client asking and the server answering. Class 11 turns that round.


Next: Class 11: Progress and Logging. A tool that works through 200 orders and says how far it has got.