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
_metaandToolContextToMcpMetaConverter: 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
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
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:
| Connection | Declared by the annotations | Set by the customizer | Roots | Request timeout |
|---|---|---|---|---|
orders | elicitation, from the @McpElicitation handler in Class 12 | the timeout only, capabilities left alone | none | 2 minutes |
knowledge-base | nothing | roots(true), replacing an empty declaration | support-kb | 30 seconds, from the property |
knowledge-base-archive | nothing | roots(true) | support-kb-archive | 30 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:
{
"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:
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.
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:
| Question | Directory in the server's args | Roots |
|---|---|---|
| Who decides the folder | the server's own configuration in application.yaml | the client |
| When it can change | by editing the configuration and restarting the server process | while both processes stay up, and the change applies immediately |
| What if the server ignores it | it cannot: the folder is the only one it was given | it may ignore the list, and the protocol does not enforce it |
| Where our support desk lands | enough on its own, because the two folders stay where they are | a 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.
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 working | What you see | Why |
|---|---|---|
| The Class 11 progress bar | it stays empty, and order-service logs Progress notification not supported by the client! on every context.progress(...) call | the server did not receive a token to address the notifications to |
| The Class 12 cancellation | cancel_order comes back declined | the server's question does not name a conversation, so the handler cannot tell which browser to ask |
| The tool call itself | it succeeds and the agent answers | nothing 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 used | The name | Who owns it | What changes it |
|---|---|---|---|
the tool context, in SupportAgentService | agentId | support-agent | we rename a field in our own code, and the wire does not change |
_meta on the wire | support_agent_id | order-service's interface | order-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:
| Converter | What reaches _meta | Fits |
|---|---|---|
defaultConverter() | every tool context entry, except the exchange key and null values | one server, which we wrote ourselves |
SupportMetaConverter | progressToken and support_agent_id, however the tool context grows | our three connections, two of them running code we did not write |
noOp() | nothing at all, the progress token included | an 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.
| Question | Sampling | order-service calling a model itself |
|---|---|---|
| Which model runs | the client's, and the client chooses it | the server's own |
| Whose API key and budget | the client's | the server's |
| Can the server reproduce a reported answer | no, because the model belongs to the client | yes |
Status in revision 2026-07-28 | deprecated under SEP-2577, earliest removal on or after 2027-07-28 | the 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-25 | What 2026-07-28 puts in its place | SEP |
|---|---|---|
server-initiated roots/list, sampling/createMessage, elicitation/create | Multi Round-Trip Requests: a result with resultType: "input_required", and the client retries the original request carrying inputResponses | 2322 |
Mcp-Session-Id and protocol-level sessions | removed from the Streamable HTTP transport | 2567 |
the initialize and notifications/initialized handshake from Class 2 | per-request _meta, plus server/discover for version and capability discovery | 2575 |
ping, logging/setLevel, notifications/roots/list_changed | removed, with the log level set per request in _meta | 2575 |
| Roots, Sampling, Logging | deprecated and still working, earliest removal in the first revision released on or after 2027-07-28 | 2577 |
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:
mvn -pl order-service spring-boot:run
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 server | Does a notification go out | Why |
|---|---|---|
order-service calls addTool or removeTool while running | yes, to every session connected at that moment | the SDK sends it from those two methods |
order-service is restarted | no | the 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 startup | no | the 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
- MCP specification: Roots: the normative rules for the revision this class runs on, including the
listChangedflag, theroots/listexchange, and the client's duty to validate root URIs. - MCP specification: Sampling: the
sampling/createMessagerequest and result, and the human approval the specification expects a client to put in front of it. - MCP specification: Overview and the
_metafield: the rules a_metakey follows, including the optional reverse-DNS prefix and the prefixes MCP reserves for itself. - MCP specification: Progress: where the progress token sits in a request, and the two rules it has to satisfy.
- MCP specification: Deprecated Features: the earliest removal date for Roots, Sampling and Logging, with the migration named for each.
- MCP specification: Key Changes in 2026-07-28: the full list of what the next revision changes, including Multi Round-Trip Requests and the end of the
initializehandshake. - MCP Client Boot Starter, Spring AI reference: Spring AI's own account of
McpClientCustomizer, and further down the same page theToolContextToMcpMetaConvertersection. - MCP Client Annotations, Spring AI reference: the client-side annotations with the method signature each one requires,
@McpToolListChangedincluded. - Filesystem MCP Server: the server this class points roots at, in its author's words, including how command-line directories and roots interact.
Sources
- SEP-2577: that revision
2026-07-28deprecates Roots, Sampling and Logging, and the reason it gives, which is low client adoption against the cost of implementing them. - Feature Lifecycle and Deprecation Policy: the twelve-month minimum between a feature being marked deprecated and its removal.
- MCP specification: Deprecated Features: that the earliest removal for these three is the first revision released on or after
2027-07-28, and that sampling's migration is to call a model provider directly. - MCP specification: Key Changes in 2026-07-28: that
ping,logging/setLevelandnotifications/roots/list_changedare removed, and that SEP-2322 introduces Multi Round-Trip Requests withresultType: "input_required". - MCP specification: Roots: that a root URI must be a
file://URI, that the notification is namednotifications/roots/list_changed, and that clients must validate root URIs. - MCP specification: Progress: that the progress token travels at
params._meta.progressTokenand must be unique across all active requests. - ToolContextToMcpMetaConverter, Spring AI reference: what
defaultConverter()filters out, and thatnoOp()returns an empty map. - McpAsyncClient.java, Java MCP SDK: that
addRootandremoveRootfail with anIllegalStateExceptionwhen the roots capability was not declared. - McpAsyncServer.java, Java MCP SDK: that
addToolandremoveToolbroadcastnotifications/tools/list_changedto connected sessions. - Filesystem MCP Server: that roots sent by a client replace the server's allowed directories, and that the command-line directories stand when the client does not support roots.
- Path.toUri, Java SE 21 API: that
Path.toUri()produces an absolutefile:URI, the three-slash form the filesystem server recognises. - LLM01:2025 Prompt Injection, OWASP Gen AI Security Project: that a value the model fills into a tool argument can be steered by text it has read, including content a tool fetched.