Skip to main content

Class 13: Roots, Notifications and Sampling

Duration: ~30 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 it may work with
  • The three change notifications, and what to do when a tool list moves
  • ToolContextToMcpMetaConverter, for context the model should not see
  • Sampling, and why nothing new should use it
Three features in this class are being retired

Spec revision 2026-07-28 deprecates Roots, Sampling and Logging under SEP-2577, and removes ping and logging/setLevel outright. 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 anything deprecated is removed, and nothing 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 section at the end says what to do instead.


Configuration Properties Cannot Say Everything

Everything about the client so far has been YAML. Some of it cannot be: roots are objects, a sampling handler is a function, and neither fits in a property value.

McpClientCustomizer is the way in. Spring AI calls it once per connection, with the connection name and the builder for that client:

package com.themcpguy.supportdesk.agent;

import java.net.URI;
import java.time.Duration;
import java.util.List;

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

import org.springframework.ai.mcp.customizer.McpClientCustomizer;
import org.springframework.stereotype.Component;

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

@Override
public void customize(String connectionName, McpClient.SyncSpec spec) {
if ("knowledge-base".equals(connectionName)) {
spec.roots(List.of(new Root(URI.create("file://" + System.getProperty("user.dir") + "/support-kb"), "Support knowledge base")));
}

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

The connectionName is the key from application.yaml (orders, knowledge-base, knowledge-base-archive), so one customizer configures each connection differently.

The orders branch fixes something Class 12 raised. spring.ai.mcp.client.request-timeout is global, and elicitation needs a timeout measured in minutes rather than seconds. This is the only way to give one connection a longer one.

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 is willing to have it work with. The server asks for the list, and can be told when it changes.

It is a statement of intent, not a permission. The filesystem server in Class 9 is confined by the directory in its args, enforced by the server itself. Roots are how a client says "this is what I am working on" so the server can behave sensibly, and a server is free to ignore them.

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:

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

spring.ai.mcp.client.root-change-notification defaults to true, so the server is told each time. Setting it to false means changes are silent and the server sees them only when it next asks.

Where this is genuinely useful is an editor or an agent whose working set changes: the person opens a different project, the roots change, and a server that indexes files knows to re-index. For our support desk the roots never move, which is why the customizer sets them once.


When a Server's Capabilities Change

A server may add or remove tools while running. A feature flag flips, a plugin loads, an upstream service comes back. MCP has a notification for each list, and Spring AI has a handler annotation for each:

package com.themcpguy.supportdesk.agent;

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 reason the agent needs none of this to keep working is worth knowing. SyncMcpToolCallbackProvider from Class 7 resolves tools when a request is made rather than caching them at startup, so a changed tool list is picked up on the next question without a handler. These annotations are for reacting to the change: 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.


Context the Model Should Not See

A tool sometimes needs something the model has no business knowing or choosing: which user is asking, which tenant, a request ID for tracing. Passing it as a tool argument puts it in the schema, which means the model can read it and set it.

MCP has a _meta field for this, and ToolContextToMcpMetaConverter decides what goes in it:

package com.themcpguy.supportdesk.agent;

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

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

@Component
public class SupportMetaConverter implements ToolContextToMcpMetaConverter {

@Override
public Map<String, Object> convert(ToolContext toolContext) {
Map<String, Object> meta = new HashMap<>();
if (toolContext == null || toolContext.getContext() == null) {
return meta;
}

// Pass the progress token through. Replacing the default converter and
// forgetting this stops every progress notification from Class 11, silently.
Object progressToken = toolContext.getContext().get("progressToken");
if (progressToken != null) {
meta.put("progressToken", progressToken);
}

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

The progress-token line is not optional. _meta is how the progress token reaches the server as well, so a converter that returns only its own keys removes it. Nothing fails: the server keeps calling context.progress(...), the request has no token to address them to, and the progress bar in Class 11 simply never moves. Replacing a default that carries something you did not know about is the general shape of this problem, and ToolContextToMcpMetaConverter.defaultConverter() is worth reading before replacing it.

Set it when making the request:

chatClient.prompt()
.toolContext(Map.of("agentId", currentUser.id()))
.user(userMessage)
.call()
.content();

And read it on the server with the McpMeta parameter from Class 4:

public Order getOrder(String orderId, McpMeta meta) {
auditLog.record(meta.get("support_agent_id"), "read", orderId);
return ...;
}

The audit trail now records which person asked, and neither the model nor the prompt could change it. The built-in ToolContextToMcpMetaConverter.defaultConverter() passes the whole tool context through minus nulls and the MCP exchange key; noOp() passes nothing.


Sampling

Sampling is the reverse of everything else. The server asks the client to run a model completion, and uses the answer to finish its work. A server that wants to summarise something can borrow the client's model rather than 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.

Nothing new should use it. Spec revision 2026-07-28 deprecates sampling 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 to it would let a tool call a model itself, with the server's own key, its own budget and no dependency on what the client happens to have.

The reasoning behind the deprecation is worth understanding even though the feature is going. Sampling makes a server's behaviour depend on a model it did not choose and cannot see. The same tool gives different answers to different clients, which is difficult to test and harder to support.

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 changes the shape of everything in Classes 11 and 12, and it is worth knowing before planning anything long-lived.

Server-initiated requests (roots/list, sampling/createMessage and elicitation/create) are replaced by Multi Round-Trip Requests (SEP-2322). Instead of the server sending a request down an open connection while a tool runs, the tool returns a result with resultType: "input_required", and the client calls the original request again carrying the answers.

That removes the hard part of Class 12. There is no thread parked waiting on a person, because the tool call finished. The state moves to the client, which retries when it has the answer.

Protocol-level sessions go with it: Mcp-Session-Id and the initialize handshake from Class 2 are both removed, replaced by a server/discover call and per-request metadata.

None of this is available in Spring AI 2.0.0, which implements 2025-11-25. When it arrives it will be a new version of this course rather than an edit of this one, because the code changes rather than the explanation.



Check It

Restart both and confirm nothing broke. The customizer runs once per connection, so a mistake in it stops the agent starting rather than failing quietly later:

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 -Dspring-boot.run.profiles=cli

The inspector reports the same three connections as Class 10. To see the customizer's timeout take effect, ask for the bulk job from Class 11: it now has five minutes rather than thirty seconds, which is what Class 12 needs.

The change-notification handlers are harder to trigger deliberately, because they fire when a server changes its capabilities. The straightforward way is to comment out one @McpTool method in order-service, restart it while the agent keeps running, and watch for:

order-service now offers 5 tools

That also demonstrates the point from further up: the agent keeps working through it, because tool callbacks are resolved per request.


What We Built

The client is configured in Java where properties could not reach it, tells the knowledge-base server what it is working on, reacts when a server's capabilities move, and passes the acting user through to the audit log without the model seeing it.

That is the whole two-way surface. Class 14 tests all of it.


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