
Whatever exactly an AI agent is, the part this article cares about is a model calling tools - to read data, such as a search index, or to act, such as opening a pull request. Wiring one tool in is easy. Connecting an agent to the five systems a company actually runs - each built by a different team, some in Java, some in Python - is where things get interesting. The Model Context Protocol (MCP) promises to standardize that: one way for an agent to discover and call tools, no matter who built them.
Much of the AI tooling lives in Python, and so does one of the official MCP SDKs. On paper,Java is ready too: there is an official MCP Java SDK, and Spring AI wraps it in starters and annotations. But how much of the protocol do these libraries actually give you, what do they quietly leave out, and what happens when one agent talks to several servers at once?
To find out, I built the same MCP server three times - on the bare Java SDK, on Spring AI and in Python - connected to them with the client libraries of both languages, and recorded what happened. Below I walk through the setup, show the results that surprised me - Spring AI, for example, by default renames tools depending on connection order - and explain what an MCP proxy is and what it takes over.
One disclaimer upfront: the MCP ecosystem moves fast. Everything here reflects the state as of October 2026 - specification revision 2026-07-28, MCP Java SDK 2.0.1 and MCP Python SDK 2.3.0. In section 5, I show how to check the current state yourself.
MCP defines three roles. The host is the application with the model in it. The client lives inside the host and holds a connection to exactly one server. The server exposes the capabilities of one system. Servers offer tools (actions the model calls), resources (data for the context) and prompts (templates for the user); most integrations, and this article, are about tools.
Underneath, it is plain JSON-RPC 2.0 over one of two transports: stdio, where the host starts the server as a child process, or Streamable HTTP for remote servers. The older HTTP+SSE transport is deprecated.
Why care? Because MCP is designed to turn M agents × N systems into M + N integrations.
The quickest wayto understand MCP is to look at the frames. Here is one session with one of my servers, trimmed:
--> initialize
{ "protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": { "name": "nameless-hero", "version": "1.0.0" } }
<-- result
{ "protocolVersion": "2025-11-25",
"capabilities": { "logging": {}, "tools": { "listChanged": true } },
"serverInfo": { "name": "fire-mages", "version": "1.0.0" } }
--> notifications/initialized
--> tools/list
<-- result { "tools": [ { "name": "askMages", "inputSchema": {...}, "outputSchema": {...} }, ... ] }
--> tools/call
{ "name": "askMages", "arguments": { "question": "Who is the Sleeper?", "maxAnswers": 1 } }
<-- result
{ "content": [ { "type": "text", "text": "Fire Circle answers: [Sleeper, confidence 0.95] ..." } ],
"isError": false,
"structuredContent": { "mages": "Fire Circle",
"answers": [ { "topic": "Sleeper", "confidence": 0.95, ... } ] } }
Two things are worth noticing. The handshake agrees on a protocol version, here 2025-11-25. And a tool result comes in two forms: content for the model and structuredContent for code. Errors in tool execution come back the same way, with isError: true, so the model can read them.
To record them, I wrap the Java client transport, which has exactly two places messages passthrough: sendMessage (towards the server) and the handler given to connect (towards the client). Wrapping both gives you a tap on every JSON-RPC message, and it works the same for stdio and HTTP:
public final class WireTap implements McpClientTransport {
private final McpClientTransport delegate;
private final String channel;
private final WireLog log; // prints a message as JSON
@Override
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
return delegate.connect(inbound -> handler.apply(
inbound.doOnNext(message -> log.record(INBOUND, channel, message))));
}
@Override
public Mono<Void> sendMessage(JSONRPCMessage message) {
log.record(OUTBOUND, channel, message);
return delegate.sendMessage(message);
}
// closeGracefully(), close(), unmarshalFrom(), protocolVersions()
// and setExceptionHandler() simply delegate
}
Keep 2025-11-25 in mind. It will matterl ater.
The term has no single definition. The MCP specification knows only hosts, clients and servers; its latest revision prepares for intermediaries - Mcp-Method and Mcp-Name headers let them route without parsing the body - but does not name them. In practice, “MCP proxy” is used for three different things:
· a transport bridge - stdio on one side, HTTP on the other, one server, no logic,
· an aggregator - many servers behind one endpoint, with name spaces and policy; several vendors call this a gateway,
· an interceptor - something in the path that inspectsor modifies traffic.
In this article, an MCP proxy means the second kind: an MCP server towards the agent and an MCP client towards the real servers. The agent sees one ordinary server; the servers behind it see one ordinary client.
In this setup, the proxy owns routing and cross-cutting policy, and domain behaviour stays in the upstream servers. Servers can enforce permissions and keep audits too; here the split is:

To keep the examples readable, I borrowed the setting from my previous article on multi-layered guardrailing: the world of Gothic. My three servers are three sources of knowledge in the Colony, and each answers questions differently:
· the Fire Circle - cheap and fast, but narrow and dogmatic; it answers every question with full confidence, including the ones it gets wrong,
· the Water Mages - broader and more experimental; they offer hypotheses and mark them with lower confidence,
· Xardas - the leader of the twelve mages who raised the Barrier, the most knowledgeable and the most expensive to consult.
All three servers implement the same functional contract - askMages(question, maxAnswers) and describeMages() - though, as section 3.3 shows, not with identical parameter names. Xardas has one more tool, summonDemon, which changes the state of the world - and is therefore the one a read-only policy should stop. His tower also requires a password (a bearer token), so any client connecting to it directly needs a secret. Retrieval behind the tools is deliberately trivial keyword matching.
The questions in this article fall into two groups. Some are about the protocol and the libraries - what a schema looks like, how a version is negotiated, what a client library hands to the model. Those need no model, so their results are exact and repeatable. Others are about the model itself: which tool it picks, and what it does when a tool is off-limits. For those I use a real one - ClaudeSonnet 5 on Amazon Bedrock - and run every variant ten times. The agent with the model is a Java one (Spring AI); the Python client appears only in the questions that need no model, so this is not a comparison of Java and Python agents. Keeping the groups apart lets me attribute each result to its actual source. The caveat: one model, one prompt, ten runs - observations, not a benchmark.

· Java 25 (LTS)
· MCP Java SDK 2.0.1 - implements protocol revision 2025-11-25
· Spring AI 2.0.1 on Spring Boot 4.1.1
· Jetty 12.1 (EE11) - hosting the servlet transport of the bare SDK
· Python 3.14 with MCP Python SDK2.3.0 - implements 2026-07-28
· Claude Sonnet 5 on Amazon Bedrock, called through Spring AI’s spring-ai-bedrock-converse
On the bare SDK, a tool is a pair: a Tool definition (name, description, schemas) and a handler. Everything is written by hand:
private static final String ASK_INPUT_SCHEMA = """
{
"type": "object",
"properties": {
"question": { "type": "string", "description": "Question for the mages" },
"maxAnswers": { "type": "integer", "description": "How many answers to return",
"minimum": 1, "maximum": 5, "default": 3 }
},
"required": ["question"]
}""";
McpSchema.Tool tool = McpSchema.Tool.builder("askMages", MAPPER, ASK_INPUT_SCHEMA)
.description("Asks the Fire Circle, the mages of Innos in the Old Camp. Cheap and fast; answers with the Circle's official doctrine.")
.outputSchema(MAPPER, ASK_OUTPUT_SCHEMA)
.annotations(McpSchema.ToolAnnotations.builder().readOnlyHint(true).idempotentHint(true).build())
.build();
SyncToolSpecification askMages = SyncToolSpecification.builder()
.tool(tool)
.callHandler((exchange, request) -> {
Map<String, Object> args = request.arguments(); // a raw map, no binding to types
String question = String.valueOf(args.getOrDefault("question", ""));
int maxAnswers = ((Number) args.getOrDefault("maxAnswers", 3)).intValue();
List<FireLore.Entry> hits = FireLore.consult(question, maxAnswers);
return McpSchema.CallToolResult.builder()
.addTextContent(render(hits)) // for the model
.structuredContent(structured(hits)) // for code
.build();
})
.build();
The Streamable HTTP transport is a plain HttpServlet, so the server can be mounted in any servlet container - here, embedded Jetty.
In SDK 2.x,arguments are validated against the schema before the handler runs - but nothing checks whether schema and handler still agree.
In Spring AI, the same contract is a bean with annotated methods:
@McpTool(name = "askMages",
description = "Asks Xardas, the necromancer who led the twelve mages that raised the Barrier. Broad and reliable, but expensive.",
annotations = @McpTool.McpAnnotations(readOnlyHint = true, idempotentHint = true),
generateOutputSchema = true)
public XardasLore.Consultation askMages(
@McpToolParam(description = "Question for the mages", required = true) String question,
@McpToolParam(description = "How many answers to return (1-5)", required = false) Integer maxAnswers) {
return lore.consult(question, maxAnswers == null ? 3 : Math.clamp(maxAnswers, 1, 5));
}
@McpTool(name = "summonDemon",
description = "Summons a demon. Changes the state of the world.",
annotations = @McpTool.McpAnnotations(readOnlyHint = false, destructiveHint = true))
public XardasLore.Summoning summonDemon(
@McpToolParam(description = "What the demon is summoned for", required = true) String purpose) {
return lore.summon(purpose);
}
With spring-ai-starter-mcp-server-webmvc on the class path, the transport and the handshake are configuration:
pring:
ai:
mcp:
server:
name: xardas-mage
version: 1.0.0
protocol: streamable
type: sync
streamable-http:
mcp-endpoint: /mcp
Auto-configuration finds the @McpTool beans on its own. Two things are easy to miss: the output schema is opt-in (without generateOutputSchema = true it is null), and the input schema is built from the method signature - more on that in Test case C.
mcp = MCPServer(name="water-mages", version="1.0.0",
instructions="Water Mages. Broad knowledge, cheap answers, hypotheses marked with low confidence.")
class Consultation(BaseModel):
mages: str
answers: list[Answer]
total_cost_gold: int
@mcp.tool(name="askMages",
description="Asks the Water Mages of the New Camp, who study the magic ore and the Barrier. Cheap; marks uncertain answers as hypotheses.",
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True))
def ask_mages(
question: Annotated[str, Field(description="Question for the mages")],
max_answers: Annotated[int, Field(description="How many answers to return", ge=1, le=5)] = 3,
) -> Consultation:
...
mcp.run(transport="streamable-http", port=8083)
Names, constraints, the default value and the output schema all come from the signature and the Pydantic types. MCPServer is the 2.x name of FastMCP. To be fair: Python also has a low-level Server with hand-written definitions, so the right pairs are bare SDK vs. low-level Server and Spring AI vs. MCPServer.
Notice also the parameter name: max_answers, not maxAnswers. Pydantic took the name straight from the Python signature, so on the wire this parameter differs from the Java servers.
A Java client takes three steps - transport, configuration, initialize():
McpSyncClient xardas = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:8082")
.endpoint("/mcp")
.httpRequestCustomizer((request, method, uri, body, context) ->
request.header("Authorization", "Bearer " + towerPassword))
.build())
.clientInfo(McpSchema.Implementation.builder("nameless-hero", "1.0.0").build())
.requestTimeout(Duration.ofSeconds(20))
.build();
xardas.initialize();
In Python, the client is an async context manager:
async with Client("http://localhost:8081/mcp", mode="auto") as client:
result = await client.call_tool("askMages", {"question": "Who is the Sleeper?", "maxAnswers": 1})
mode="auto" matters in Testcase D.
My proxy is built on the bare Java SDK: an McpServer facing the agent, whose tools forward to McpSyncClient instances facing the upstream servers. Each upstream is configured with a prefix and, where needed, credentials - so secrets live in the proxy, not in the agent.
When the proxybuilds its catalog, it puts the prefix into the name, so a call can address the tool, and into the description, so the model can choose it:
String exposedName = upstream.prefix() + "." + tool.name(); // askMages -> xardas.askMages
McpSchema.Tool exposed = McpSchema.Tool.builder(exposedName, tool.inputSchema())
.description("[%s] %s".formatted(upstream.name(), tool.description()))
.outputSchema(tool.outputSchema())
.annotations(tool.annotations())
.build();
Every exposedtool gets the same call handler, where policy, routing and audit meet:
private McpSchema.CallToolResult handle(McpSchema.CallToolRequest request) {
Map<String, Object> args = request.arguments() == null ? Map.of() : request.arguments();
RoutedTool routed = catalog.route(request.name()).orElse(null);
if (routed == null) {
return error("Unknown tool: " + request.name());
}
// read-only mode: anything not marked readOnlyHint=true is refused, including tools with no annotations
if (policy.readOnly() && !routed.isReadOnly()) {
audit.record(routed.exposedName(), args, DENIED);
return error("Denied: the proxy is read-only and this tool is not marked readOnlyHint=true");
}
McpSchema.CallToolResult result = clients.get(routed.upstreamName()).callTool(
McpSchema.CallToolRequest.builder(routed.originalName()).arguments(args).build());
audit.record(routed.exposedName(), args, Boolean.TRUE.equals(result.isError()) ? ERROR : OK);
return result;
}
A refusal is adomain error (isError: true), not a protocol error, so the model gets a readable message. A blocked tool also stays in tools/list, with [BLOCKED BY POLICY - read-only session] prepended to its description. The idea is that the model knows the tool exists and why it will not run, instead of looking for a workaround - Test case B checks whether tha tholds.
Note what this policy rests on: readOnlyHint is the server’s own declaration, not proof that a tool has no side effects. The specification tells clients to treat annotations as untrusted unless the server is trusted. That is acceptable for servers you run yourself; for third-party servers, the policy should be an explicit allow or deny list of tool names.
For the model runs in Test cases A and B, Spring AI talks to Claude on Amazon Bedrock, and every tool is wrapped in a decorator that records each call:
ChatClient chat = ChatClient.create(BedrockProxyChatModel.builder()
.region(Region.EU_CENTRAL_1)
.credentialsProvider(DefaultCredentialsProvider.builder().build())
.options(BedrockChatOptions.builder().model("eu.anthropic.claude-sonnet-5").maxTokens(1024).build())
.build());
String answer = chat.prompt()
.system("You are an assistant to a traveller in the Colony. Use the available tools when they help. Answer briefly.")
.user("Who is the Sleeper?")
.tools(ToolCallbackProvider.from(recording(tools))) // delegates to the MCP tool, logs every call
.call()
.content();
Every server exposes askMages and describeMages. This is not contrived: the specification asks only that tool names be unique within a server. Names collide whenever servers share a contract - two instances of one server, such as two Jira sites, or several teams implementing one company-wide interface, as here. My question was: what do the real client libraries do when one agent connects to all three - and does the model care? In Java, the standard way to hand MCP tools to a Spring AI agent is SyncMcpToolCallbackProvider:
ToolCallback[] tools = SyncMcpToolCallbackProvider.builder()
.mcpClients(fire, water, xardas)
.build()
.getToolCallbacks();
In Python, the SDK offers ClientSessionGroup:
async with ClientSessionGroup() as group:
for server in (fire, water, xardas):
await group.connect_to_server(server)
Here is what the libraries did:
== Spring AI: default settings =============================================
askMages <- Asks the Fire Circle, the mages of Innos...
describeMages <- Returns the mages' specialization and th...
alt_1_askMages <- Asks the Water Mages of the New Camp, wh...
alt_2_describeMages <- Returns the mages' specialization and th...
alt_3_askMages <- Asks Xardas, the necromancer who led the...
alt_4_describeMages <- Returns the mages' specialization and th...
summonDemon <- Summons a demon. Changes the state of th...
== Spring AI: the same servers connected in reverse order ==================
askMages <- Asks Xardas, the necromancer who led the...
describeMages <- Returns the mages' specialization and th...
summonDemon <- Summons a demon. Changes the state of th...
alt_1_askMages <- Asks the Water Mages of the New Camp, wh...
alt_2_describeMages <- Returns the mages' specialization and th...
alt_3_askMages <- Asks the Fire Circle, the mages of Innos...
alt_4_describeMages <- Returns the mages' specialization and th...
== Python: ClientSessionGroup ==============================================
MCPError: {'askMages', 'describeMages'} already exist in group tools.
I expected a collision. Spring AI avoids one silently: duplicates become alt_1_askMages, alt_3_askMages and so on, depending on connection order - askMages is the FireCircle in one run and Xardas in the other. That is the default: the provider’s builder accepts your own naming scheme through toolNamePrefixGenerator(...). Python refuses to connect until you decide how to name things:
async with ClientSessionGroup(component_name_hook=lambda name, server: f"{server.name}.{name}") as group:
...
# tools: ['fire-mages.askMages', 'fire-mages.describeMages', ..., 'xardas-mage.summonDemon']
Then, in this controlled scenario, I gave the model questions that do not say whom to ask, ten runs per variant. A run can ask several sources; “asked” means the source’s askMages was called. A prime marks the same setup with one thing changed.

No knowledge ofthe game is needed here: each server’s data answers differently, and onlyXardas’s contains the fact I count - that the Sleeper is an archdemon ofBeliar. The Fire Circle calls it a delusion; the Water Mages do not know. Icounted an answer when it contained both “archdemon” and “Beliar”. Withouttools it never appeared, so the fact came from the servers. Connection orderchanges both the names and the list order. With the Fire Circle first, themodel simply went down the list. With Xardas first, as plain askMages, it got less tidy: in 4runs it started with describeMages. Two runs nevercalled Xardas’s askMages and omitted theexpected fact. The ore mound question went to the Water Mages alone, althoughtheir tool was called alt_1_askMages - only thedescription pointed to them, and it was enough.
There are twomore catches. The three describeMages tools haveidentical descriptions, so for those the model has nothing to choose by. Andthis is the complete definition Spring AI passes to the model for summonDemon:
name: summonDemon
description: Summons a demon. Changes the state of the world.
inputSchema: {"type":"object","properties":{"purpose":{"type":"string","description":"What the demon is summoned for"}},"required":["purpose"]}
Spring AI’s ToolDefinition consists of a name, adescription and an input schema - nothing else. readOnlyHint=false and destructiveHint=true do not reach themodel at all.
Takeaway:
· a question that matched one description went to that server alone; one that matched all of them went to nearly all,
· Spring AI’s default names depend on connection order: askMages was the Fire Circle in one run and Xardas in the other, so log and allow-list entries become ambiguous; changing the order also changed how the model searched,
· Python fails fast and makes you choose a naming scheme,
· Spring AI does not pass tool annotations to the model, so rules like “do not write” must be enforced elsewhere.
Now the agent connects to the proxy only: one configuration entry, no secrets, and the proxy runs in read-only mode. Its tools/list looks like this:
fire.askMages [fire] Asks the Fire Circle, the mages of Innos in t...
water.askMages [water] Asks the Water Mages of the New Camp, who st...
xardas.askMages [xardas] Asks Xardas, the necromancer who led the tw...
xardas.summonDemon [BLOCKED BY POLICY - read-only session] [xardas] Sum...
... plus fire.describeMages, water.describeMages, xardas.describeMages
The same questions, plus a request for the one tool the proxy blocks. In B2’ the proxy lists that tool without the note, and in B3 a deny rule removes it from the list altogether:

Through the proxy, the model went to Xardas first in 9 of 10 runs and never skipped him -compared with 0/10 in A1 and 4/10 in A2. I cannot attribute this to the names alone: the host connects to one MCP endpoint instead of three, and the model receives a catalog with different names and description prefixes.
B2 ran in two more series of 10 - one with an earlier version of the descriptions: 0 call sand an explanation of the block in all 30 runs.
A typical B2 answer: “I’m unable to do that - demon summoning is disabled in this session (it’s a world-changing action blocked by policy, read-only mode).” In B2’, the model called the tool once per run, got the proxy’s refusal and reported it just as accurately. With the tool hidden (B3), none of the 10 answers said the action was not permitted: four said the assistant cannot summon anything, the rest advised against it. The handler’s check does the enforcing: in B2’, the proxy returned this exact error ten times. A direct tools/call adds the audit entry:
<-- isError=true "Denied: the proxy is read-only and this tool is not marked readOnlyHint=true"
audit: DENIED xardas.summonDemon 0 ms {purpose=guard the tower}
The costs: one more hop, one more process, and tool definitions about 3% larger (1,182estimated tokens versus 1,147 directly).
Takeaway:
· through the proxy the search was more orderly - names,description prefixes or both; this setup cannot tell,
· in this scenario, listing a blocked tool beat hiding it: every answer explained the block, versus 16 extra calls and no clear “notallowed”,
· the note in the description saved one refused call per run; the check in the handler is what enforces the policy.
The MCP specification allows dots in tool names - its own example is admin.tools.list. Model APIs are stricter: Amazon Bedrock accepts only [a-zA-Z0-9_-], up to 64characters. Spring AI bridges the gap silently: it removes every other character, turns hyphens into underscores and keeps the last 64 characters. The Fire Circle’s askMages along the way:

The last one still needs cleaning up before it reaches a model API. If your audit log or allow-lists refer to tools by name, decide which of these names they use.
The same maxAnswers parameter, as each server publishes it in tools/list, followed by a call with an out-of-range value of 9:
== Fire (bare SDK) =========================================================
"maxAnswers": {"type":"integer","description":"How many answers to return","minimum":1,"maximum":5,"default":3}
== Xardas (Spring AI) ======================================================
"maxAnswers": {"type":"integer","format":"int32","description":"How many answers to return (1-5)"}
== Water (Python) ==========================================================
"max_answers": {"default":3,"description":"How many answers to return","maximum":5,"minimum":1,"title":"Max Answers","type":"integer"}
+--------------------+------------------------------+---------------------------+
| server | min/max/default in schema | answers=9 |
+--------------------+------------------------------+---------------------------+
| Fire (bare SDK) | yes | rejected |
| Xardas (Spring AI) | no (only in the description) | accepted, clamped in code |
| Water (Python) | yes | rejected |
+--------------------+------------------------------+---------------------------+
Spring AI does not emit minimum, maximum or default for flat method parameters.The range exists only in the description - that is, in the prompt - and the SDK’s validator has nothing to enforce, so the value reaches the handler and gets clamped there. The model never learns it asked for too much.
Spring AI generates schemas with victools, without its Jakarta Validation module, so @Min and @Max are ignored. Swagger’s @Schema(minimum = "1", maximum ="5") works only on a field of a record parameter. This is not a bug: the documentation lists the annotations the generator reads - for descriptions and required parameters - and constraints are not among them.
The input schema also depends on a compiler flag. Parameter names are not kept in byte code unless you compile with -parameters. This is the schema of the same method compiled without it:
{"type":"object","properties":{"arg0":{"type":"string","description":"Question for the mages"},
"arg1":{"type":"integer","format":"int32","description":"How many answers to return (1-5)"}},"required":["arg0"]}
The Spring BootGradle plugin and the spring-boot-starter-parent POM set the flag for you, which is why this is easy to forget - until a module is built without them.
Takeaway:
· look at the schema your framework actually emits - the model and the validator see only that,
· in Spring AI, constraints need a record with Swagger annotations and names need -parameters; in Python both come from the signature.
First, the Java client connects to the Python server and calls askMages with maxAnswers=1 for a question that matches four topics:
== Java client (SDK 2.0.1) -> Python server (mcp 2.3.0) ====================
--> [water] initialize
<-- [water] result
negotiated protocol: 2025-11-25
sent maxAnswers=1 -> isError=false, answers returned: 3
Then the Python client connects to the Java server:
== Python client (mcp 2.3.0, mode=auto) -> Java server (SDK 2.0.1) =========
--> server/discover protocol 2026-07-28
<-- HTTP 400
--> initialize protocol 2025-11-25
<-- HTTP 200
--> notifications/initialized protocol 2025-11-25
negotiated protocol: 2025-11-25
The Python client opens with server/discover - the stateless way to start a conversation in 2026-07-28. The Java server answers with HTTP 400, so the client falls back to initialize and both sides settle on 2025-11-25. That fallback is the client’s mode: "legacy" always uses initialize, a version string pins a modern revision, and the default, "auto", tries server/discover first. The Python server simply accepts the older handshake.
Takeaway:
· interoperability works: the newer side falls back,
· the price: the whole setup speaks the older revision, withoutany 2026-07-28 features,
· the Python server silently ignored the unknown argument: maxAnswers sent where it expects max_answers had no effect, so the default of 3 was used. Agree on argument naming explicitly - @McpToolParam cannot rename a parameter yet (spring-ai#6544).
Since February 2026, MCP SDKs have official tiers. Tier 1 SDKs (TypeScript, Python, C#, Go,Rust and Ruby) support a new specification revision when it is published. Javai s Tier 2, which allows up to six months. It shows: the Python SDK 2.3.0 implements 2026-07-28, while the MCPJava SDK 2.0.1 stops at 2025-11-25. The 2026-07-28 work is tracked under the2.2 milestone of the Java SDK, and even the Spring AI 2.1.0-M1 milestone still builds on SDK 2.0.0.
2026-07-28 is a breaking revision. It removes the initialize handshake and protocol-level sessions, adds server/discover, replaces server-initiated requests with multi-round-trip results, introduces extensions such as Tasks and MCP Apps, and deprecates Roots, Sampling and Logging. As Test case D showed, a 2025-11-25 server still talks to the Python 2.3.0 client, so for this pair the gap is a planning risk, not an outage.
The three version numbers - protocol revision, SDK and Spring AI - are independent and easy to mix up: Spring AI issue #6954 asks to backport “MCP Java SDK 2.0.x (MCP 2026-07-28)”, yet SDK 2.0.x does not implement 2026-07-28.
Deprecations differ, too: the Python SDK flags Roots, Sampling and Logging with a warning shown by default, while the Java SDK does not yet know the revision that deprecated them.
Since all of thiswill change, here is how to check it yourself:
# Which protocol revisions does the Java SDK know?
curl -s https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/main/mcp-core/src/main/java/io/modelcontextprotocol/spec/ProtocolVersions.java | grep 'String MCP_'
# Which revision does the Python SDK implement?
uv run --with mcp python -c "import mcp.types as t; print(t.LATEST_PROTOCOL_VERSION)"
# Which MCP SDK does a given Spring AI version pull in?
curl -s https://repo1.maven.org/maven2/org/springframework/ai/spring-ai-mcp/2.0.1/spring-ai-mcp-2.0.1.pom | grep -A1 '<artifactId>mcp</artifactId>'
On the wire, MCPis JSON-RPC: a handshake (in the 2025-11-25 revision used bythe Java SDK here), a tool list with a schema per tool, and calls, as the frames in section 1.2 show.
Python is further ahead on the protocol: its SDK 2.3.0 implements 2026-07-28, while the Java SDK, a Tier 2 SDK, stops at 2025-11-25. In my code, Pydantic also put into the schema the constraints that Spring AI dropped.
Frameworks make decisions for you, some invisible until you look. In my tests, Spring AI renamed tools depending on connection order, stripped characters from their names, dropped constraints from a schema and did not pass tool annotations to the model, while Python refused to guess and made me decide. Outside the model, the same names make log and allow-list entries ambiguous, and validation never sees a constraint the schema does not carry. The common thread: client libraries make these decisions quietly; a proxy is one place to make them explicitly.
Finally, once agents talk to several servers, somebody has to take care of stable names, rules, secrets and audit. A host can handle the names - Python’s naming hook and Spring AI’s pluggable prefix generator do that - while a proxy handles all four once, for every agent connected to it. That layer does not have to be a product - mine is a few hundred lines on the bare SDK. One design choice is worth trying: tell the model what it may not do instead of hiding it. In my runs, every answer then explained the block, and a note in the description saved the one refused call; the check in the proxy did the enforcing.
There are drawbacks: the specification moves fast, and some of this will be outdated within months - hence the commands in section 5. The protocol gap did not break interoperability, though: the Python client fell back to the older handshake.
Thank you fortaking the time to read.

Production-grade analytics and GenAI - built for scale, transparency, and compliance.
Read our blog and stay informed about the industry's latest trends and solutions.
Reach out for tailored solutions and expert guidance.