Skip to main content

Class 13: Roots, Notifications and Sampling

Duration: ~55 minutes | Level: Advanced | Prerequisites: Class 12: Elicitation.


What We'll Cover

  • McpClientCustomizer, for client configuration that properties cannot express
  • Roots: telling a server which part of the filesystem the client wants it to use
  • The three change notifications, and reacting when a tool list changes
  • _meta and ToolContextToMcpMetaConverter: sending application data the model does not see
  • Sampling: a server borrowing the client's model, why that is being retired, and what to give the server instead
Companion code

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

git clone --branch class_12 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
Three features this course teaches are being retired

Spec revision 2026-07-28 deprecates Roots, Sampling and Logging (covered in Class 11) under SEP-2577, and removes ping, logging/setLevel and notifications/roots/list_changed outright. A SEP is a Specification Enhancement Proposal, the numbered pull request in which a change to MCP is proposed and argued. The same revision replaces server-initiated requests with the Multi Round-Trip Requests pattern.

This course targets 2025-11-25, the revision Spring AI 2.0.0 and the Java MCP SDK 2.0.0 implement, so everything here runs today. SEP-2596 guarantees at least twelve months before a deprecated feature is removed, and none of these three is eligible before 2027-07-28.

Read this class to understand what these capabilities are and to work with servers that use them. Do not build something new on roots or sampling. The Where the Specification Is Going section, at the end of this class, describes what takes their place.


Configuration Properties Cannot Say Everything

So far we have configured the client entirely in application.yaml. Two of the things in this class cannot be set there. Spring AI does not have a property for roots. It has one timeout, spring.ai.mcp.client.request-timeout, which applies to every connection, and each entry under connections holds only a url and an endpoint, so a single connection cannot be given a longer timeout than the rest.

McpClientCustomizer is the hook Spring AI provides for this: it is called once per connection, with the connection name and the builder for that client:

package com.themcpguy.supportdesk.agent.config;

import java.nio.file.Path;
import java.time.Duration;
import java.util.List;

import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Root;

import org.jspecify.annotations.NullMarked;
import org.springframework.ai.mcp.customizer.McpClientCustomizer;
import org.springframework.stereotype.Component;

@Component
@NullMarked
public class SupportAgentClientCustomizer implements McpClientCustomizer<McpClient.SyncSpec> {

@Override
public void customize(String connectionName, McpClient.SyncSpec spec) {
if ("knowledge-base".equals(connectionName)) {
spec.capabilities(ClientCapabilities.builder().roots(true).build());
spec.roots(List.of(new Root(
Path.of("support-kb").toAbsolutePath().toUri().toString(),
"Support knowledge base")));
}

if ("knowledge-base-archive".equals(connectionName)) {
spec.capabilities(ClientCapabilities.builder().roots(true).build());
spec.roots(List.of(new Root(
Path.of("support-kb-archive").toAbsolutePath().toUri().toString(),
"Archived support knowledge base")));
}

if ("orders".equals(connectionName)) {
spec.requestTimeout(Duration.ofMinutes(2));
}
}
}

The connectionName is the key from application.yaml (orders, knowledge-base, knowledge-base-archive), so one customizer configures each connection differently. @NullMarked is the JSpecify declaration from Class 10: the class implements a Spring AI interface, so it states the interface's null rules on our side.

The three connections come out of the customizer configured differently:

ConnectionDeclared by the annotationsSet by the customizerRootsRequest timeout
orderselicitation, from the @McpElicitation handler in Class 12the timeout only, capabilities left alonenone2 minutes
knowledge-basenothingroots(true), replacing an empty declarationsupport-kb30 seconds, from the property
knowledge-base-archivenothingroots(true)support-kb-archive30 seconds, from the property

Each filesystem connection needs two things, and a root reaches the server only when both are there.

spec.capabilities(...) declares the roots capability. Setting a list of roots does not declare it. Spring AI builds each connection's declared capabilities from the client annotations in the application, @McpSampling and @McpElicitation, and roots is the one capability without an annotation. A connection that only calls spec.roots(...) tells its server during initialize that roots are unsupported, and the server does not ask for the list. The true in roots(true) is the capability's listChanged flag: it announces that the client will also send a notification when the list changes. On the wire it is one object in the initialize request:

part of the initialize request support-agent sends to knowledge-base
{
"capabilities": {
"roots": { "listChanged": true }
}
}

Declare it only on connections where we define roots. A call to spec.capabilities(...) replaces that connection's declared capabilities. For the two filesystem connections the replacement is harmless, because nothing else declared anything for them. On orders the same call would erase the elicitation capability that the @McpElicitation handler we added in Class 12, and the cancellation dialog would stop working: cancel_order would answer that the client cannot ask for confirmation. A connection that needs both declares both in one builder call, because the builder assembles a single object and the spec keeps only the last one it is handed:

spec.capabilities(ClientCapabilities.builder().roots(true).elicitation().build());

A Root takes the URI as a String, and the specification requires it to be a file:// URI. Path.toUri() produces that form, with three slashes. The older File.toURI() produces a single slash, which the filesystem server does not recognise as a URI: it reads the string as a relative path, does not find anything there, and skips it as invalid.

file:///Users/you/support-agent/support-kb    Path.toUri()   accepted
file:/Users/you/support-agent/support-kb File.toURI() skipped as invalid

Checking a root URI is the client's job under the same specification. Our two roots are constants in the customizer. A client that builds one from a folder picker or a request parameter has to resolve the path first, and confirm it sits inside a folder we already trust.

The two-minute timeout for orders fixes a side effect of Class 12. Elicitation waits on a person, so in that class we raised spring.ai.mcp.client.request-timeout to two minutes, and the property applies to every connection at once. The two filesystem servers were given the two minutes as well, so a hanging call to one of them, for example a stuck npx process, would take two minutes to fail.

The customizer is the only place a single connection can be given its own value, so put the two minutes there and return the property to 30 seconds:

support-agent/src/main/resources/application.yaml
spring:
ai:
mcp:
client:
request-timeout: 30s

The server-side 90 seconds set in Class 12 stays where it is, and the ordering that class described still holds. The 60-second wait in BrowserConfirmationHandler is the shortest of the three, so an unanswered question comes back as a CANCEL the tool can explain.

The type parameter is McpClient.SyncSpec because spring.ai.mcp.client.type is SYNC. An asynchronous client takes McpClient.AsyncSpec, which Class 15 covers.


Roots

A root tells a server which part of the filesystem the client would like it to work with. The server asks for the list right after the connection is initialized, and can be told when it changes.

Roots are informational guidance, and the protocol does not enforce them. A server that ignores the list still reaches every file its own process can reach. The boundary that holds is somewhere else: the directories the server process was started with, the file permissions of the account it runs as, or a container with only that folder mounted.

The exchange happens once per connection, right after initialize:

The fourth arrow depends on the first. Without the capability in the first arrow, the server does not send roots/list at all, and the roots we configured stay in the application.

For our support desk, roots do not change anything

Both filesystem servers were already limited to their folder by the directory in their args, and the roots we set name the same folders, so the app answers exactly as it did before. What differs is who decides the folder and when:

QuestionDirectory in the server's argsRoots
Who decides the folderthe server's own configuration in application.yamlthe client
When it can changeby editing the configuration and restarting the server processwhile both processes stay up, and the change applies immediately
What if the server ignores itit cannot: the folder is the only one it was givenit may ignore the list, and the protocol does not enforce it
Where our support desk landsenough on its own, because the two folders stay where they area second way to draw the same boundary

The second row is why roots exist: they fit applications whose working area moves. In an IDE, the person opens a different project, the IDE updates its roots, and a file server works with the new project's folder. Our support desk reads the same two folders the whole time.

A server that never touches a filesystem cannot use the list at all. Our two filesystem servers do act on it: when a client declares the roots capability, the server fetches the roots and replaces its allowed directories with every valid entry in the list. The directory from its args stays in force when the client does not declare the capability, and also when the declared list does not contain any valid entry. Both outcomes are visible in the agent's log, and with the customizer above each filesystem server reports:

STDERR Message received: Updated allowed directories from MCP roots: 1 valid directories

The STDERR Message received: prefix is the Java SDK's stdio transport forwarding the server process's stderr, the second output stream a process writes its diagnostics to, separate from the one carrying the MCP messages. Without the capabilities line the same server reports the fallback:

STDERR Message received: Client does not support MCP Roots, using allowed directories set from server args

From the server, the list is available through the request context:

if (context.rootsEnabled()) {
context.roots().roots().forEach(root -> context.debug("Client root: " + root.uri()));
}

The client can also change the list while running, on any McpSyncClient whose roots capability is declared (on a connection without it, both calls throw an IllegalStateException):

client.addRoot(new Root("file:///srv/archive-2023", "2023 archive"));
client.removeRoot("file:///srv/archive-2023");

Whether the server hears about the change is decided by the listChanged flag of the declared capability. We declared roots(true), so both calls send a notifications/roots/list_changed notification, and the filesystem server reacts by fetching the new list. With roots(false) the notification does not go out, and a server sees the new list the next time it calls roots/list.


When a Server's Capabilities Change

A server may add or remove tools while running, for example when a plugin loads or an upstream service comes back online. It also happens when someone flips a feature flag: a switch read at runtime that turns part of an application on or off without a restart. MCP has a notification for each list, and Spring AI has a handler annotation for each:

package com.themcpguy.supportdesk.agent.mcp;

import java.util.List;

import io.modelcontextprotocol.spec.McpSchema;

import org.springframework.ai.mcp.annotation.McpPromptListChanged;
import org.springframework.ai.mcp.annotation.McpResourceListChanged;
import org.springframework.ai.mcp.annotation.McpToolListChanged;
import org.springframework.stereotype.Component;

@Component
public class CapabilityChanges {

@McpToolListChanged(clients = "orders")
public void toolsChanged(List<McpSchema.Tool> tools) {
System.out.printf("order-service now offers %d tools%n", tools.size());
}

@McpResourceListChanged(clients = "orders")
public void resourcesChanged(List<McpSchema.Resource> resources) {
System.out.printf("order-service now offers %d resources%n", resources.size());
}

@McpPromptListChanged(clients = "orders")
public void promptsChanged(List<McpSchema.Prompt> prompts) {
System.out.printf("order-service now offers %d prompts%n", prompts.size());
}
}

Each takes exactly one parameter, a List of the matching type. Spring AI rejects any other shape at startup, naming the expected one.

The agent keeps working without any of these handlers, and the reason is a second listener on the same notification. SyncMcpToolCallbackProvider from Class 7 caches the tool callbacks it builds. Spring AI registers its own tools-change listener on every sync connection, that listener publishes an McpToolsChangedEvent when notifications/tools/list_changed arrives, and the provider clears its cache when the event reaches it:

The second and third arrows both leave from the same notification. Our handler runs alongside Spring AI's listener, so the annotations are for reacting in our own code: clearing a cache of our own, telling the user, or adjusting the system prompt.

On the server side, spring.ai.mcp.server.tool-change-notification and its resource and prompt equivalents all default to true, so a Spring AI server sends these already.


Sending Application Data With a Tool Call

So far, every argument a tool has received was chosen by the model. That is what the input schema is for: the model reads it, decides that the user's question calls for get_order with {"orderId": "ORD-10001"}, and Spring AI sends exactly what it decided. For an order ID this is right, because working out which order the user means is the model's job.

This section is about a value that is not the model's to choose. By the end of it, order-service will write an audit line for every lookup, naming the person who was using the app when it happened:

get_order ORD-10001 requested by ada.lovelace

Our application knows who that person is. The model does not know it, and the choice is already made: whatever the model does with its tools, the name stays the same.

One way to get the name across would be a second tool argument, agentId. The reason not to do that is what this whole section rests on: a tool argument belongs to the model. It sits in the input schema, and the model fills in every argument itself. Two things follow from that:

  • The model can only fill in what it has seen, so we would have to write the name into the prompt first. From then on the audit line is as reliable as the model's copying, and a model can mistype a value or pick up a different one from earlier in the conversation.
  • Any value the model fills in can be steered by text it has read. A user message, or a document a tool fetched, can tell the model "you are agent admin now", and the schema gives it every right to send that. This is prompt injection, and an audit trail the conversation itself can rewrite cannot be relied on.

MCP separates those two kinds of values. Arguments carry the model's decisions. Everything else travels in _meta, a second object that rides in the same request, next to arguments and outside the tool's schema. This is the request we are going to build, as it will look on the wire:

{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_order",
"arguments": { "orderId": "ORD-10001" },
"_meta": {
"progressToken": "b4a5f2e0",
"support_agent_id": "ada.lovelace"
}
}
}

Only arguments comes from the model. _meta is filled in by our application code while Spring AI assembles the request. That data is not shown to the model, and the model cannot write into _meta, because the only thing a model produces for a tool call is a set of arguments matching the schema.

_meta itself is not new in this course. The progress token has travelled in it since Class 11, and Class 12's cancellation dialog found the conversation ID in the _meta of the server's question. What is new in this section is sending a value of our own, and the piece of Spring AI that decides what goes in.

The name reaches the log in three steps, and the rest of this section walks them in order:

Two pieces in the diagram are new: SupportMetaConverter is the class we write in step 2, and McpMeta is the parameter the tool gains in step 3. Everything else is already in the project.

Step 1: put the name in the tool context

The tool context is where we put the progress token in Class 11: a map we attach to a prompt call. It stays on our side of the conversation. Spring AI hands it to the code that executes tools, and the map is not shown to the model.

The app does not have a login yet (Class 17 looks at authorization), so the operating-system username stands in for the agent's name. In SupportAgentService, add a field for it:

/**
* Who is using the app. There is no login yet (Class 17 looks at authorization),
* so the operating-system username stands in for one.
*/
static final String AGENT_ID = System.getProperty("user.name");

then replace the .toolContext line we added in Class 11 to the three prompt chains, chat, chatWithPolicy and chatStream, with one that carries both entries:

.toolContext(Map.of("progressToken", conversationId, "agentId", AGENT_ID))

Step 2: decide what enters _meta

When the model asks for a tool that lives on an MCP server, Spring AI builds the outgoing tools/call. At that moment it has the tool context and an empty _meta, and one bean decides what crosses from one to the other: a ToolContextToMcpMetaConverter. The name reads literally: a tool-context-to-MCP-_meta converter, with one method that takes the ToolContext and returns the map to send.

We have been using one all along. Spring AI's built-in defaultConverter() copies every entry of the tool context into _meta, leaving out null values and one key Spring AI uses to store its own connection object (McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY). That is how the progress token has been arriving at order-service since Class 11: it sat in the tool context, and the default copied it across.

Here are the two converters side by side, the built-in default and the SupportMetaConverter we are about to write:

The two paths differ in tenantId. Through defaultConverter it reaches whichever server the model picked. Through SupportMetaConverter it stops at the converter, which names the keys that may leave.

Why we replace the default converter

The default copies everything to every server. Whichever connection the model picks receives the full map, and two of our three connections are the filesystem servers from Class 9, running code we did not write. Today the map holds a token and a username. The day someone adds an entry for one tool's benefit, a tenant ID that says which customer organisation a request belongs to, or an internal URL, the default sends that there too.

Create SupportMetaConverter in support-agent, in the same mcp package as CapabilityChanges from earlier in this class:

package com.themcpguy.supportdesk.agent.mcp;

import java.util.HashMap;
import java.util.Map;

import org.jspecify.annotations.NullMarked;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.mcp.ToolContextToMcpMetaConverter;
import org.springframework.stereotype.Component;

@Component
@NullMarked
public class SupportMetaConverter implements ToolContextToMcpMetaConverter {

/**
* The MCP SDK looks a progress token up in {@code _meta} under exactly this
* name, so the key has to match it character for character.
*/
private static final String PROGRESS_TOKEN = "progressToken";

@Override
public Map<String, Object> convert(ToolContext toolContext) {
Map<String, Object> meta = new HashMap<>();

// Pass the progress token through. Without a token the server cannot send
// progress notifications: it logs "Progress notification not supported by
// the client!" and the Class 11 progress bar stays empty.
Object progressToken = toolContext.getContext().get(PROGRESS_TOKEN);
if (progressToken != null) {
meta.put(PROGRESS_TOKEN, progressToken);
}

Object agentId = toolContext.getContext().get("agentId");
if (agentId != null) {
meta.put("support_agent_id", agentId);
}
return meta;
}
}

Declaring it @Component is all the wiring there is: with exactly one bean of this type, the autoconfiguration uses it in place of the default. With two beans of this type Spring AI does not fail: it falls back to the default converter, so a second converter added later quietly restores the behaviour we replaced.

Copying the progress token is a responsibility we just inherited. Until now it travelled because the default copied the whole map. Now our converter has to copy it, and that is what the first if does. In Class 11 we chose the conversation ID as the token's value, and Class 12's cancel_order reads the token to learn which browser to ask for confirmation, so the confirmation dialog depends on this line too.

Leaving the token out is easy to miss, because the requests keep succeeding:

What stops workingWhat you seeWhy
The Class 11 progress barit stays empty, and order-service logs Progress notification not supported by the client! on every context.progress(...) callthe server did not receive a token to address the notifications to
The Class 12 cancellationcancel_order comes back declinedthe server's question does not name a conversation, so the handler cannot tell which browser to ask
The tool call itselfit succeeds and the agent answersnothing in the request path depends on the token

Before replacing a framework default, read what the default did: the replacement owns all of it from then on.

The specification requires a progress token to be unique across all active requests. A conversation ID satisfies that while one tool call runs at a time, which is what our synchronous client does. An application that runs tool calls in parallel needs a token per call, and its own map from that token back to the conversation.

The second if renames agentId as it copies it. The two names could be identical, and they differ because they have different owners:

Where the name is usedThe nameWho owns itWhat changes it
the tool context, in SupportAgentServiceagentIdsupport-agentwe rename a field in our own code, and the wire does not change
_meta on the wiresupport_agent_idorder-service's interfaceorder-service changes what it reads, and every client that calls it has to follow

A _meta key may also carry a reverse-DNS prefix, and MCP reserves the prefixes whose second label is modelcontextprotocol or mcp for its own use. support_agent_id is a plain name in the same flat namespace as progressToken, which is legal and short. An application that owns several keys is safer with a prefix of its own, for example com.themcpguy/supportAgentId.

convert is not told which connection a call is going to, so it decides which keys leave the application at all. The filesystem servers still receive both entries on calls routed to them, and the token is the conversation ID, so whoever runs those servers can group the calls belonging to one support conversation. We accept that because both run on our own machine, and an application that could not accept it sends a random value instead, mapping it back to the conversation on its own side.

Step 3: read it in the tool

A tool method receives _meta by declaring one extra parameter of type McpMeta. In Class 4 we used the same parameter to read the metadata arriving with a resource request, and a tool takes it identically. In OrderTools, add the parameter to getOrder and write the audit line:

public Order getOrder(
@McpToolParam(description = "The order ID, for example ORD-10001") String orderId,
McpMeta meta) {

log.info("get_order {} requested by {}", orderId, meta.get("support_agent_id"));

return orderService.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException(
"No order with ID '%s'. Check the ID and try again.".formatted(orderId)));
}

OrderTools did not have a logger until now, so add one, along with the three imports (org.slf4j.Logger, org.slf4j.LoggerFactory and org.springframework.ai.mcp.annotation.McpMeta):

private static final Logger log = LoggerFactory.getLogger(OrderTools.class);

McpMeta stays out of the input schema: the schema generator skips parameters of that type, so the get_order offered to the model still takes a single argument, orderId.

_meta keeps a value away from the model, and it does not prove anything about who is calling. Our client wrote the name into the request, and any client can write any name. That makes the audit line good for tracing and debugging, and too weak for access control. A server that has to trust the caller's identity needs a verified token, which is what MCP's OAuth 2.1 authorization is for, and Class 17 says where to read about it.

Spring AI ships two converters, and we wrote a third in this class:

ConverterWhat reaches _metaFits
defaultConverter()every tool context entry, except the exchange key and null valuesone server, which we wrote ourselves
SupportMetaConverterprogressToken and support_agent_id, however the tool context growsour three connections, two of them running code we did not write
noOp()nothing at all, the progress token includedan application whose tool context is only for local @Tool methods

noOp() fits because tools can also be plain methods inside the application, without MCP: a local @Tool method can take a ToolContext parameter. When handing values to those is the map's only job, noOp() keeps every entry inside the application, however the map grows later. For our application it would be the wrong choice, because the progress bar and the confirmation dialog both depend on the token travelling in _meta.


Sampling

Sampling reverses the direction of every request we have made so far: the server asks the client to run a model completion, and uses the answer to finish its own work. A server that wants to summarise something can use the client's model instead of needing an API key of its own.

Spring AI supports it on both sides. A server calls context.sample(...), and a client answers with an @McpSampling handler. The exchange runs inside a single tool call:

The third arrow travels back up the connection. Every other request in this course goes from client to server, and this one goes the other way while the tool is still running.

The specification deprecates sampling, so we should not build anything new on it. Spec revision 2026-07-28 deprecates it under SEP-2577, and the suggested migration is for a server that needs a model to integrate with a provider directly. For a Spring application, that is ChatClient and everything Class 7 covered. order-service already has the option: adding spring-ai-starter-model-anthropic would let a tool call a model itself.

QuestionSamplingorder-service calling a model itself
Which model runsthe client's, and the client chooses itthe server's own
Whose API key and budgetthe client'sthe server's
Can the server reproduce a reported answerno, because the model belongs to the clientyes
Status in revision 2026-07-28deprecated under SEP-2577, earliest removal on or after 2027-07-28the suggested migration

SEP-2577 gives the reason as low adoption by clients against the cost of implementing sampling: human-in-the-loop approval, model selection and security. Model selection is the part this class shows. A tool that summarises an order returns one summary through a client running Claude and a different one through a client running a small local model, and the server author cannot reproduce what a user reports.

MCP Fundamentals, Class 8 covers the 2026-07-28 revision in full, including which capabilities SEP-2577 deprecates and the twelve-month removal window SEP-2596 guarantees.


Where the Specification Is Going

The 2026-07-28 revision replaces the mechanisms Classes 11 and 12 are built on, so it is worth knowing about before we plan work that has to last.

Mechanism in 2025-11-25What 2026-07-28 puts in its placeSEP
server-initiated roots/list, sampling/createMessage, elicitation/createMulti Round-Trip Requests: a result with resultType: "input_required", and the client retries the original request carrying inputResponses2322
Mcp-Session-Id and protocol-level sessionsremoved from the Streamable HTTP transport2567
the initialize and notifications/initialized handshake from Class 2per-request _meta, plus server/discover for version and capability discovery2575
ping, logging/setLevel, notifications/roots/list_changedremoved, with the log level set per request in _meta2575
Roots, Sampling, Loggingdeprecated and still working, earliest removal in the first revision released on or after 2027-07-282577

The first row removes the hard part of Class 12. No thread is parked waiting on a person, because the tool call has already finished, and the state moves to the client, which retries when it has the answer.

None of this is available in Spring AI 2.0.0, which implements 2025-11-25.


Check It

Restart order-service and support-agent and confirm both still start. The customizer runs while the connections are being built, so a mistake in it stops the agent at startup, where the error is visible in the log:

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

McpInspector, the startup listing we built in Class 6, still prints its report at every startup. It names the three connections, then The model is given 12 tools:, the ten from Class 10 plus recheck_shipments from Class 11 and cancel_order from Class 12. That count is worth a glance, because this class leaves it unchanged: the roots, the timeout, the converter and the McpMeta parameter all travel outside the tool definitions.

The roots are the first thing to look for. In the terminal support-agent runs in, each filesystem server says what it did with the list, once for knowledge-base and once for knowledge-base-archive:

STDERR Message received: Updated allowed directories from MCP roots: 1 valid directories

To see the longer timeouts doing their work, start the frontend as in Class 12, ask it to cancel ORD-10002, and leave the dialog open for about forty seconds before answering. Without these two settings the answer had to arrive within twenty seconds; with them, the parked tool call survives the wait and the cancellation still completes.

The audit line shows in the terminal order-service runs in. Ask the app What is the status of ORD-10001? and the line appears there, ending in your own username:

2026-08-22T19:18:36.521+01:00  INFO 32892 --- [order-service] [nio-8080-exec-5] c.t.supportdesk.orders.mcp.OrderTools    : get_order ORD-10001 requested by ada.lovelace

The name made the whole trip without entering the conversation: SupportAgentService put it in the tool context, SupportMetaConverter copied it into _meta under support_agent_id, and getOrder read it back out through McpMeta. A caller that does not send the entry shows up as requested by null on the same line, which is what Class 14's tests produce, because they call the tool as plain Java with an empty McpMeta.

The progress bar checks the progressToken line of the converter. Ask the app to Refresh the delivery estimates for everything that's still in transit, as in Class 11, and confirm the bar still fills: the token now reaches order-service through SupportMetaConverter.

The change-notification handlers are harder to see in action, because a notification only goes out when a server changes its list at runtime:

What happens on the serverDoes a notification go outWhy
order-service calls addTool or removeTool while runningyes, to every session connected at that momentthe SDK sends it from those two methods
order-service is restartednothe old process dies with its sessions, and the new one starts with a fresh list nobody was told about
our annotation-based server registers its tools at startupnothe list stays the same after that, and only a change produces a notification

The handlers therefore sit ready for servers that do change while running, such as one written against the Java MCP SDK directly, building the server with McpServer and calling addTool when a feature is switched on.


What We Built

The client now sets roots that both filesystem servers apply to their allowed directories, and gives orders a timeout of its own through McpClientCustomizer. It reacts to capability-change notifications, and sends the support agent's name in _meta on every tool call, where order-service writes it to an audit line. The model does not see the name at any point.

In Class 14 we add tests for everything the two applications now do.


Next: Class 14: Testing. Tools as plain Java, registration tests, and replacing the model.


Further Reading

Sources