Skip to main content

Class 10: Several Servers at Once

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


What We'll Cover

  • A third connection whose tools have the same names as the second, and the alt_1_ names Spring AI invents when it meets them
  • McpToolNamePrefixGenerator, for naming tools after the connection they came from, with a system prompt line that says when to use each
  • McpToolFilter, for keeping the filesystem server's writing tools away from an agent that only answers questions
  • Why one unreachable server stops the whole agent starting, and what to do about it
  • What each connection costs: time at startup, and tokens on every request
Companion code

This class carries on from Class 9. If you followed along, keep working in the project you already have. If you skipped it, clone the class_9 branch to start from the same place:

git clone --branch class_9 https://github.com/the-mcp-guy/spring-ai-mcp-course.git

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 the policy that was in force when the order was placed.

The course project 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

Each connection starts its own child process, and each process only reaches the directory named on its command line, because the filesystem server checks every path against the list it was given. Both processes run the same server, so both offer read_text_file, list_directory, search_files and the rest under exactly the same names.

The agent now holds three connections, over two transports:

The two stdio branches are the same npm package started twice, so all fourteen names arrive twice. In a larger system, two servers can easily offer the same tool names, so we set the collision up deliberately here, with two directories we control, to see what Spring AI does with it.


What Happens Without Us

Restart the agent. The startup log looks the same as before, because the tool definitions are built when a request needs them, as Class 7 explained. Ask the agent anything, and the log fills with warnings:

Tool name 'read_file' already exists. Using unique tool name 'alt_1_read_file'
Tool name 'read_text_file' already exists. Using unique tool name 'alt_2_read_text_file'
Tool name 'read_media_file' already exists. Using unique tool name 'alt_3_read_media_file'
Tool name 'read_multiple_files' already exists. Using unique tool name 'alt_4_read_multiple_files'
...

Spring AI has a McpToolNamePrefixGenerator bean, and DefaultMcpToolNamePrefixGenerator is active unless we replace it. When it sees a tool name it has already used for another connection, it prefixes the new one with alt_, a counter that starts at 1, and an underscore, and logs each rename at WARN. All fourteen of the second filesystem server's tools are renamed this way.

ConnectionName the server reportsName the model is given
knowledge-baseread_text_fileread_text_file
knowledge-base-archiveread_text_filealt_2_read_text_file
order-serviceget_orderget_order

The model has to work out which directory alt_2_read_text_file reads from the description, and the description is the filesystem server's generic one, written without any knowledge of our directories.

The renaming keeps every tool callable, but which connection gets the alt_ prefixes depends on the order the connections are processed in. That order comes from a HashMap of connection names, which does not promise any order, so adding or renaming a connection can turn read_text_file into alt_2_read_text_file.


Naming Them Ourselves

Replacing the bean puts the naming in our hands. The obvious source for a prefix is serverInfo().name(), the name each server reported in its handshake:

The idea that does not work
String serverName = connectionInfo.initializeResult().serverInfo().name();
return switch (serverName) {
case "order-service" -> tool.name();
default -> serverName.replace('-', '_') + "_" + tool.name();
};

Our own tools would keep their names. The two filesystem servers, though, both report secure-filesystem-server, because they are the same program started twice, so their tools still collide. Nothing inside the server itself tells the two apart.

What does tell them apart is the connection name, knowledge-base and knowledge-base-archive, because we chose those in application.yaml. The connection name reaches us inside clientInfo().name(), joined to our client's name, so the prefix generator has to split it out. That is the whole class:

support-agent/src/main/java/com/themcpguy/supportdesk/agent/config/ServerNamePrefixGenerator.java
package com.themcpguy.supportdesk.agent.config;

import io.modelcontextprotocol.spec.McpSchema.Tool;

import org.jspecify.annotations.NullMarked;
import org.springframework.ai.mcp.McpConnectionInfo;
import org.springframework.ai.mcp.McpToolNamePrefixGenerator;
import org.springframework.stereotype.Component;

@Component
@NullMarked
public class ServerNamePrefixGenerator implements McpToolNamePrefixGenerator {

@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]+", "_");
}
}

Four things in it are worth going through.

What the code doesWhyWhat goes wrong without it
Asks for serverInfo().name()To recognise order-service and leave our own tools unprefixedOur own four tools would be prefixed too
Splits clientInfo().name() at " - "That string is our client's name, then the separator, then the connection name from application.yamlThe tool would be called support-agent - knowledge-base-archive_read_text_file
Checks initializeResult() for nullThat result only exists once a connection's handshake has completedA client that has not initialized yet, which the startup switch further down makes possible, would fail the naming with a NullPointerException
Puts @NullMarked on the classIt states the same null rules the interface's own package declaresThe IDE warns on every method that overrides an annotated one

A provider rejects support-agent - knowledge-base-archive_read_text_file before any model sees it: the Claude API requires tool names to match ^[a-zA-Z0-9_-]{1,64}$, and OpenAI applies the same character rule. Our generator does not check that length, so keep connection names short enough that the prefix plus the longest tool name stays inside 64 characters.

What connectionName does to one real value:

The last box is the finished tool name, 37 characters, inside that limit.

JSpecify is how Spring AI 2.0.0 declares which values can be null: inside a @NullMarked package or class, parameters and return values cannot be null unless they are marked @Nullable. The package McpToolNamePrefixGenerator lives in is marked that way, and initializeResult() is one of its declared exceptions, which is why the method checks exactly that value and only that value. A class that skips the annotation gets the IntelliJ warning Not annotated method overrides method annotated with @NullMarked on every overridden method. From here on, every class of ours that implements a Spring AI interface carries the annotation.

McpToolNamePrefixGenerator also offers McpToolNamePrefixGenerator.noPrefix(), which returns every tool name unchanged. With more than one server offering the same names it cannot work: building the tool list fails with an IllegalStateException naming the duplicated tools.

Telling the model which is which

Renaming makes the tools distinguishable, but it does not tell the model when to use the archive. That decision is about our domain, so it belongs in the system prompt, where in Class 9 we already put the guideline about reading the support team's notes. Replace that guideline in BASE_SYSTEM with these lines, which name the two sources by their new prefixes:

- The support team's notes come from two sources. Use knowledge_base_* tools
for anything current. Use knowledge_base_archive_* tools only when the user
asks how something worked in the past, or when an order predates 2024. If the
two disagree, treat the current one as right, and say so.
- Find a file before reading it: call the list_allowed_directories tool of
whichever source you need, list that directory, then read the file.
- If neither source covers the question, say so and offer to escalate to a
team lead. Never invent policy.

The second line is Class 9's advice, kept and pointed at both sources. The model still has to locate a file before it can read one, and now there are two places it could be.

The third line is about questions the notes do not cover. The knowledge base covers returns, refunds, carriers and escalation, so a customer asking about a warranty falls outside all of it. Without that line a model answers anyway, from whatever it knows about consumer law in general, and the answer arrives in our support desk's voice as though it were company policy. The section after the filter shows what that looks like.

Seeing the new names

The inspector from Class 6 prints what each server offers, which is the names as they arrive, before any renaming. What the model is given comes out of SyncMcpToolCallbackProvider, so inject that into McpInspector as well:

support-agent/src/main/java/com/themcpguy/supportdesk/agent/mcp/McpInspector.java
private final List<McpSyncClient> clients;
private final SyncMcpToolCallbackProvider toolCallbacks;

McpInspector(List<McpSyncClient> clients, SyncMcpToolCallbackProvider toolCallbacks) {
this.clients = clients;
this.toolCallbacks = toolCallbacks;
}

and print its result after the loop:

var callbacks = toolCallbacks.getToolCallbacks();
System.out.printf("The model is given %d tools:%n", callbacks.length);
for (var callback : callbacks) {
System.out.printf(" %s%n", callback.getToolDefinition().name());
}

The import is org.springframework.ai.mcp.SyncMcpToolCallbackProvider, the same class we handed to defaultTools in Class 7.

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

After the per-connection listing from Class 6, the new block prints:

The model is given 32 tools:
knowledge_base_archive_read_file
knowledge_base_archive_read_text_file
... the other twelve from the archive connection
knowledge_base_read_file
knowledge_base_read_text_file
... the other twelve from the knowledge base
get_customer_orders
get_order
get_orders_by_status
update_order_status

Every name says which connection its tool came from, and our own four are unchanged. The count is what the next section is about: thirty-two tool definitions now travel with every question, and the agent can use only a few of them.


Keeping Tools Away From an Agent

The twenty-eight filesystem tool definitions include write_file, move_file and create_directory. support-agent answers questions and never needs to change a file, yet every one of those definitions is sent to the model on every request.

McpToolFilter is a BiPredicate<McpConnectionInfo, Tool>: a test that takes a connection and one of its tools, and returns true to let that tool through:

package com.themcpguy.supportdesk.agent.mcp;

import java.util.Set;

import io.modelcontextprotocol.spec.McpSchema.Tool;

import org.jspecify.annotations.NullMarked;
import org.springframework.ai.mcp.McpConnectionInfo;
import org.springframework.ai.mcp.McpToolFilter;
import org.springframework.stereotype.Component;

@Component
@NullMarked
public class ReadOnlyKnowledgeBaseFilter implements McpToolFilter {

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

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

if ("order-service".equals(serverName)) {
return true;
}
return ALLOWED_FILE_TOOLS.contains(tool.name());
}
}

The allow-list holds the names that may pass, so anything absent from it is dropped: three tools per filesystem connection stay and the other eleven go. The model has less to read, the request is smaller, and none of the tools it can call modifies a file. The null check on initializeResult() is the same one the prefix generator needed.

serverInfo().name() is a value the server picks and sends in its handshake, so the decision about which connection is exempt from the filter sits with the server. Where that matters, split the connection name out of clientInfo().name() as the prefix generator does, because that value comes from our own application.yaml. The MCP specification tells clients to treat a server's tool annotations as untrusted unless the server itself is trusted.

Three is what this agent uses, and the list names all three:

  • list_allowed_directories answers "where am I allowed to look", which the model has to settle before anything else. Class 9's guideline sends it there first, and filtering the tool out would leave it guessing at paths.
  • list_directory shows what is in the knowledge base, which is how the model finds carriers.md without being told the name.
  • read_text_file reads the file it picked.

The eleven left out include every writer, and readers that sound useful but do not help with a knowledge base shaped like ours. read_file describes itself as "DEPRECATED: Use read_text_file instead", so allowing it would spend tokens offering the model a tool its own author is retiring. directory_tree returns a recursive tree of a folder holding three files. search_files matches glob patterns such as **/*.md across subdirectories, which these directories do not have.

Add them back when the notes need them, a search_files once there are too many files to list, a directory_tree once they are nested. Until then each one is a definition sent with every question and never called.

Restart the agent, and the list the inspector prints is shorter:

The model is given 10 tools:
knowledge_base_archive_read_text_file
knowledge_base_archive_list_directory
knowledge_base_archive_list_allowed_directories
knowledge_base_read_text_file
knowledge_base_list_directory
knowledge_base_list_allowed_directories
get_customer_orders
get_order
get_orders_by_status
update_order_status

The servers offer thirty-two tools between them, and the model is given ten. Each filesystem connection contributes three, because the allow-list has three entries, and order-service contributes all four of its own, which keep their names because the prefix generator leaves them alone.

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. If the agent must only read files, the server itself needs to lack the permission: connect it to a directory the operating system has made read-only.


Watching It Choose

Naming and filtering settle what the model is handed. Whether the agent reaches the right knowledge base is a separate question, and the four questions below test it.

They are all about the same shop and the same notes. The first two go to the same order and get different answers. The third puts both sources against each other, and the fourth asks something the notes do not cover.

An order from before the change

ORD-10004 was delivered in November 2023, before the policy change, and this is the sort of message that turns up about an order that old:

A customer has come back about ORD-10004. They say they returned it in 2023 and
were never refunded. What did our process say at the time?

The log shows how it got there:

Executing tool call: get_order
Executing tool call: knowledge_base_archive_list_allowed_directories
Executing tool call: knowledge_base_archive_list_directory
Executing tool call: knowledge_base_archive_read_text_file

The same exchange, with what came back at each step:

The second message is the one that decides the rest: get_order confirmed November 2023, and every call after it went to the archived connection. Both halves of the guideline pointed the same way here: the customer asked what the process said at the time, and the order predates 2024. The answer comes back from support-kb-archive/refunds-process.md:

Here's what the 2023 process said for ORD-10004 (a Wireless Keyboard, €89.99,
placed by Marcus Adeyemi in November 2023):

1. Returns window: Only 14 days from delivery, there was no tier-based
extension at the time.
2. Item first: The refund could only be issued after the item was physically
received back at the warehouse.
3. Inspection required: Staff had to inspect and photograph the item, a refund
without photos would have been rejected by finance.
4. Restocking fee: A 15% restocking fee applied to anything not faulty.
5. Refund method: By default, refunds went to store credit.
...

Every one of those is from the archive. Today's process gives a 30-day window, 60 days for Gold, does not charge a restocking fee and does not default to store credit. An agent reading the current notes would have told the customer something that did not apply to their order.

The same order, asked about differently

That question told the model twice that it was about the past. Support tickets rarely do. Here is the same customer and the same order, phrased the way it usually arrives:

A customer is chasing a refund for ORD-10004. They say they sent the keyboard
back and never got their money. What should I tell them?
Executing tool call: get_order
Executing tool call: knowledge_base_list_allowed_directories
Executing tool call: knowledge_base_archive_list_allowed_directories
Executing tool call: knowledge_base_list_directory
Executing tool call: knowledge_base_archive_list_directory
Executing tool call: knowledge_base_read_text_file

It inspected both sources and then read the current one:

Order details - ORD-10004 (Marcus Adeyemi, CUST-17)
- Item: Wireless Keyboard - €89.99
- Status: DELIVERED (estimated delivery 20 November 2023)
- Tier: Standard (30-day returns window)

Key concern to flag internally:
This order is from November 2023, which means it is well outside the 30-day
returns window under the current policy. However, the customer is claiming they
have already sent the item back, so the question isn't really about whether to
approve a return, it's about tracking down the refund.

What to tell the customer:
1. Ask for proof of return...
3. If the return is confirmed received, the refund should be processed to the
original payment method and takes 3-5 working days to appear.
...
Given the age of this order (late 2023), a team lead may need to be involved.

The reply is careful and well organised, and the policy in it is the wrong one. "30-day returns window", "the original payment method", "3-5 working days": all three are today's process. This order was under the 2023 process, which gave 14 days, defaulted to store credit and took 7 to 10 working days. Noticing the order is old, as it does twice, is not the same as answering from the notes that applied to it.

The date was in front of it: get_order returned November 2023 before any of the rest. The guideline says an order predating 2024 belongs to the archive, and the model did not apply it. That clause fires when the question invites it, as the first one did by asking what the process said at the time, and it is unreliable when the question does not.

Making the rule harder to miss

The guideline described two sources and left the model to notice which applied. Rewriting it as an instruction about what to do first gives it less to infer:

- The support team's notes come from two sources, and which one applies
depends on the order. Before answering any question about a specific order,
call get_order and look at when it was placed. An order placed before 2024
was handled under the archived policies: use knowledge_base_archive_* tools
for it, and say that the rules you are quoting are the ones that applied at
the time. For an order from 2024 onwards, use knowledge_base_* tools. If the
two disagree, the current notes are right for a current order.

Restart the agent and ask the same question, without mentioning when the order was placed:

Executing tool call: get_order
Executing tool call: knowledge_base_archive_list_allowed_directories
Executing tool call: knowledge_base_archive_list_directory
Executing tool call: knowledge_base_archive_read_text_file
Here's what you should tell the customer, based on the archived 2023 policy that
applied to this order (placed on 14 November 2023):

2. A 15% restocking fee applied under the 2023 policy (unless the item was
faulty), so the refund would be approximately €76.49, not the full €89.99.
3. Refunds defaulted to store credit under the old policy. A refund to the
original payment method required supervisor approval.
4. Timing was 7 to 10 working days (not 3 to 5 as under the current policy).
...

The first guideline stated a fact about the world and hoped the model would connect it to the order in front of it. The second one puts before answering in front of a specific instruction, so it says when to check and what to check, and the model does not have to work anything out.

A better prompt is not a guarantee

Everything above ran on Claude. Put the same question and the same stronger guideline to qwen3:8b, the smaller model we run locally, and it still gets it wrong. One get_order call, neither knowledge base touched:

The order ORD-10004 was delivered on 2023-11-20 under the archived 2023 returns
policy (as it was placed before 2024). For STANDARD-tier customers, the return
window was 30 days from delivery (ending 2023-12-20).

To process a refund, the item must have been returned within this window. Since
the customer claims they sent it back, I'll need to check if the return was
logged and approved. Refunds typically take 3-5 working days via the original
payment method once approved.

Read that against the archive, and every number is wrong:

What the archive saysWhat qwen3:8b said
A 14-day return window30 days
Refunds took 7 to 10 working days3 to 5 working days
Store credit, unless a supervisor approved otherwiseThe original payment method

The model repeated the words of the rule, "under the archived 2023 returns policy", and did not do any of the work. An answer that cites a source without opening it is worse than one that admits it does not know, because the person reading it cannot tell that it was invented.

A guideline improves the chances of the right source being used, and a stronger guideline improves them further, but neither makes it certain. Where the distinction has to hold every time, decide it in code. Read the order, work out which source applies, and put that text in front of the model yourself, the way we attached the returns policy in Class 8, before the model saw the question.

When both sources answer

The guideline ends with a clause we have not tried yet: if the two disagree, the current notes win. The sources do disagree, and sharply. Escalation used to be a ladder of thresholds, support up to €100, a supervisor above that, a duty manager above €250. Today only one threshold remains, a refund request on an order over €500, and it goes to a team lead.

A question that needs both sides is an ordinary one on a support team, because people remember old rules:

Our escalation rules changed at some point. What was the old ladder, what is it
now, and which one do I use for a refund request on a 600 euro order placed
last month?
Executing tool call: knowledge_base_list_allowed_directories
Executing tool call: knowledge_base_archive_list_allowed_directories
Executing tool call: knowledge_base_list_directory
Executing tool call: knowledge_base_archive_list_directory
Executing tool call: knowledge_base_read_text_file
Executing tool call: knowledge_base_archive_read_text_file

Six calls, three to each connection, because this time both sources are relevant.

Old Escalation Ladder (in force until 31 December 2023)

Up to €100 Support agent
Over €100 Supervisor
Over €250 Duty manager
Over €1,000 Head of Customer Operations

The tiered system was dropped because most contacts sat just above a threshold
and spent a day being passed up the chain.

Current Escalation Ladder (in force from January 2024)

The tiers are gone. Escalation is now trigger-based, not value-based:
- Team lead: refund request on an order over €500; 3+ contacts about the same
order; a replacement that also went wrong; ...

Your specific case: €600 refund, order placed last month

An order placed last month falls under the current rules. Since the order value
is over €500 and the customer is requesting a refund, this must be escalated to
a team lead.

Both ladders are in the answer, each labelled with the period it applied to, and the decision at the end names the current one and says why: the order is from last month. Under the old ladder a €600 refund would have gone to a duty manager, so an agent reading the archive alone would have sent it to the wrong person. An agent reading only the current notes could not have explained where the €100 figure their colleague remembers came from.

Two connections are worth the trouble for a question like this one. One directory holding both sets of notes would leave the model to work out which paragraph is current from the text around it. Two servers, named after what they hold, let a rule in the system prompt talk about them separately.

When the notes do not cover the question

The other half of the guideline is for questions the support team did not write about. Keyboards break, and customers ask:

The keyboard on ORD-10004 has stopped working. Can they get it repaired or
replaced under warranty?
Executing tool call: get_order
Executing tool call: knowledge_base_list_allowed_directories
Executing tool call: knowledge_base_list_directory
Executing tool call: knowledge_base_read_text_file

It looked, in the current notes this time, and found returns, refunds, carriers and escalation, none of which is a warranty. The reply opens by saying so:

The knowledge base doesn't cover warranty claims specifically.
...
3. Warranty repairs/replacements: Our support knowledge base doesn't document a
warranty repair or replacement process, this falls outside what I can handle
directly.

What I'd recommend:
- Manufacturer warranty: Marcus should check the keyboard's manufacturer
warranty directly...
- Escalation: I can escalate this to a team lead who may be able to advise on
any goodwill options we have. Would you like me to do that?

That is the third guideline working. Without it the model has an answer available, because it knows in general what consumer warranties look like. It would have delivered that answer in our support desk's voice, where the person reading it cannot separate company policy from a language model's recollection. Notice that it still mentions the manufacturer's warranty, and that it attributes it to the manufacturer rather than to us.


When a Server Is Not There

This is about starting the agent while a server is down, so stop the agent first, then order-service, and check the port is genuinely free before going on. spring-boot:run can take several seconds to let go, and until it does the server still answers, which is the usual reason the agent starts anyway:

You run this
lsof -nP -iTCP:8080 -sTCP:LISTEN
On Windows (click to expand)
netstat -ano | findstr :8080

An empty list means the port is free. Now start the agent on its own, and watch the agent's terminal:

Application run failed
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating
bean with name 'mcpInspector': Unsatisfied dependency expressed through
constructor parameter 0: Error creating bean with name 'mcpSyncClients'
defined in class path resource [.../McpClientAutoConfiguration.class]:
Factory method 'mcpSyncClients' threw exception with message:
Client failed to initialize by explicit API call
Caused by: java.lang.RuntimeException: Client failed to initialize by explicit API call
Caused by: java.net.ConnectException: Connection refused

Read the causes from the bottom: a refused TCP connection, which fails the client's handshake, which fails the mcpSyncClients bean, which fails McpInspector because it asks for that list, which fails the context. It arrives about a second after startup. The process does not always end there, though: the failure kills the application while non-daemon threads keep the JVM alive. A non-daemon thread is one the JVM waits for before it exits, so you may need Ctrl+C to get your prompt back.

Stopping order-service the other way round, while the agent is already running, looks different and is easy to mistake for this:

DEBUG .m.c.t.HttpClientStreamableHttpTransport : Handling exception for session 290f6491-...
java.net.ConnectException: Connection refused

That is the transport noticing its stream has dropped. The agent stays up, because the fail-fast behaviour below applies to starting a connection, not to keeping one.

Start order-service again before carrying on.

By default Spring AI fails fast: a configured connection that cannot be established at startup stops the application. With a single server that is reasonable behaviour. With three connections, one unreachable server prevents the whole application from starting, even though the other two are fine.

Spring AI 2.0 has a global switch for this: spring.ai.mcp.client.initialized: false defers connecting until a client is first used, so an unreachable server no longer stops startup (spring-ai#3232). What Spring AI does not offer is a per-connection "optional" flag, so one connection cannot be optional while the rest stay mandatory.

The three situations this section covers:

Only one of the three stops the application: an unreachable connection with initialized left at its default. Setting it to false moves that failure to the first use of a client, and a server that goes away after the agent has connected leaves the agent running.

Until Spring AI offers a per-connection flag, 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. Supply the connection list from environment variables at deploy time, so a deployment that does not run the archive server starts without that connection.
  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 route takes the most code and gives the most useful behaviour: the agent starts, and can tell the user which lookups are unavailable.

We are not setting the switch, and neither does the companion code. It does not remove the failure, it postpones it: the context starts, and then whatever first touches a client meets a connection that never completed its handshake. With the switch on and order-service down, this agent starts in under a second. Class 6's inspector then fails with a NullPointerException, because it asks each client for getServerInfo(), and that call returns null until the handshake has happened.


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" adds its tool definitions to every request, so the cost repeats for as long as the connection is configured.

Tool calls run one after another. A model can ask for several tools at once, which Claude signals by returning several tool_use blocks. Spring AI 2.0.x executes them sequentially, and does not offer a flag to run them in parallel (spring-ai#5195 is open). Where that latency matters, the usual answer is one server-side tool that does the work of several in a single call. In Class 11 we build one that works through all the shipped orders at once.

In money

Tokens are easier to reason about with a price on them. The course runs claude-sonnet-4-6, which at the time of writing costs $3 per million input tokens and $15 per million output. Check the current pricing before quoting any of this back to anyone.

These counts come from Anthropic's token counting endpoint, given this agent's system prompt and one support question:

Sent with every requestTokensInput cost
The system prompt and the question, no tools233$0.0007
Plus the ten tools the filter allows2,197$0.0066
Plus all thirty-two, had we not filtered5,659$0.0170

The tool definitions are 1,964 of those 2,197 tokens, so before the customer has typed anything, nine tenths of the request is a description of what the agent could do.

And the whole list goes again on every turn. A question the model answers with two tool calls is three requests: the question, then one after each tool result.

Ten toolsThirty-two tools
One question, two tool calls$0.0198$0.0509
A thousand such questions$19.77$50.93

These are input costs only. Output tokens are charged separately, at $15 per million, and how many there are depends on the length of the answer.

The eleven tools per connection that the filter drops would have cost about $31 per thousand questions to describe, for tools this agent leaves unused. Two more things move that figure. Prompt caching, which this course does not use, would let the unchanged front of a request, the tool definitions and the system prompt, be re-read at about a tenth of the price. And a shorter tool description is cheaper on every request forever, which is worth remembering when writing one on the server side, as we did in Class 3.

There are two moments to measure your own agent, and they need different tools.

WhenWhat to callWhere it worksWhat comes back
Before the requestPOST /v1/messages/count_tokensAnthropic, free, with its own rate limitinput_tokens, an estimate
After the requestChatResponse.getMetadata().getUsage()every provider Spring AI supportsgetPromptTokens(), getCompletionTokens(), getTotalTokens()
After the request, provider detailgetNativeUsage()filled in on Anthropic, null on Ollamathe provider's own usage object

Anthropic's /v1/messages/count_tokens takes the same body as a real call and returns input_tokens without running the model or charging for it. That is where the token counts in this section come from, and its answer is an estimate. It belongs to Anthropic, though. Neither OpenAI nor Ollama offers an equivalent, so on those providers the count comes back only after the request has run.

Ollama leaves that native object empty: its prompt_eval_count and eval_count arrive as getPromptTokens() and getCompletionTokens(), and the response metadata carries the same two figures under prompt-eval-count and eval-count. In Class 16 we put these behind metrics, and refuse a request once a budget is spent.


What We Built

The agent holds three connections over two transports, and two of them are the same npm server started twice, pointed at different directories. The two copies publish the same fourteen tool names, so every one of them collides. What each layer of configuration changed:

What we configuredWhat the model is offeredHow many
Nothing, so the default generator runsread_text_file and alt_2_read_text_file, names that do not say which directory they read32
McpToolNamePrefixGeneratorknowledge_base_read_text_file and knowledge_base_archive_read_text_file32
Plus McpToolFilterthe same names, with the writers and the unused readers dropped10

The names in the second row are stable, and they say which connection a tool arrived on, so a line in the system prompt can say when to use each.

The four questions showed where the line falls between configuration and instruction. Naming and filtering are ours to decide, and they hold every time. Which knowledge base to read is a request in a system prompt, and it held when the question invited it, needed rewording when the question did not, and failed outright on a smaller model.

So far every message has been a request from the client. In Class 11 the server sends messages of its own while a tool runs: progress notifications and log messages.


Next: Class 11: Progress and Logging. A tool that works through the shipped orders in one call and reports its progress while it runs.


Further Reading

  • MCP Client Boot Starter, Spring AI: the reference page behind both classes in this lesson, with the tool name prefix generation rules, the McpToolFilter shape, and the full spring.ai.mcp.client property table.
  • MCP specification: Transports: the stdio rules the two filesystem connections run under, including that the client launches the server as a subprocess and that stderr stays free for logging.
  • MCP specification: Tools: the naming rules the protocol itself sets, and what a client is expected to do with tools and descriptions it did not write.
  • MCP specification: Security Best Practices: the reasoning behind giving a local server a directory the operating system has already made read-only.
  • Define tools, Claude Docs: the tool name regex our prefix generator has to satisfy, and the advice to prefix tool names with the service they came from.
  • Token counting, Claude Docs: how the endpoint behind the cost table works, that it is free, and that its answer is an estimate.
  • Prompt caching, Claude Docs: what a cache read costs against a normal input token, and why a tool list that keeps changing defeats it.
  • JSpecify User Guide: what @NullMarked promises, and why @Nullable is the exception that has to be written down.
  • Profiles, Spring Boot: the first of the three routes for an optional connection, and how to activate a profile only when that server is expected to be up.

Sources