Tested against. Spring Boot 4.1.2, Spring AI 2.0.2, MCP Java SDK 2.1.0 (the latest GA on Maven Central when this was written; checked againstmaven-metadata.xml), JDK Temurin 25.0.5+8. The SDK negotiates MCP protocol version2025-11-25(see 01-initialize.txt). A newer spec revision,2026-07-28, was published on July 28, 2026 and changes how sampling and elicitation travel; the section “What changes in 2026-07-28” says exactly how, and marks which parts were run and which were read.
A tool is only one of three things a server can offer
MCP gives a server three ways to put something in front of a model-powered application, and the difference between them is who decides to use it. A tool is something the model chooses to call. A prompt is a template the user picks, the way you pick a slash command. A resource is read-only data the application chooses to attach, addressed by a URI, like a file path. Then there are two features that run the other way. In sampling the server asks the client to run a prompt through the client’s own model, so the server needs no API key. In elicitation the server asks the client to put a question to the human and send back the answer.Intermediate: why three kinds of data and not just tools?
Putting everything behind a tool makes the model the gatekeeper of everything. If the model has to decide to call read_runbook before it can see the runbook, you have turned a document lookup into a reasoning step that can be skipped or got wrong. A resource lets the host application (a chat UI with an attach button, an IDE) put the runbook in context without the model deciding anything. A prompt does the same for a workflow the user wants to trigger by name.
The cost is that clients decide what to expose. A server can publish resources and prompts, but nothing forces a given client to show them. Treat them as an enhancement over a tool-only baseline, not a replacement for one. One third-party client matrix counted, among 24 clients with documented MCP support, 13 supporting elicitation and 2 supporting sampling (see this write-up); I did not verify those counts myself.
Going deeper
- Spec 2025-11-25: resources
- Spec 2025-11-25: prompts
- The tools half: Build an MCP Server with Spring AI 2.0
The server in one picture, and the one rule to remember
The server is an ordinary Spring Boot app with the Spring AI MCP server starter. It uses the Streamable HTTP transport, which is Spring AI 2.0’s default. Spring AI finds methods annotated@McpResource, @McpPrompt and @McpTool and registers them; you write plain methods.
The server’s only configuration is application.properties. The one line to notice is the protocol, which later in the post gets flipped:
spring.application.name=mcp-advanced
spring.ai.mcp.server.name=incident-desk
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.type=SYNC
# STREAMABLE is Spring AI 2.0's default; written out because the demo's stateless test flips it.
spring.ai.mcp.server.protocol=STREAMABLE
spring.ai.mcp.server.request-timeout=20s
server.port=8089
To check the setup works, the first test connects with the plain MCP SDK client and prints what the two sides agreed during the handshake. The client declares that it can do sampling and elicitation; the server declares what it offers.
Output of 01-initialize.txt:
# initialize handshake (client declares sampling + elicitation)
server.name: incident-desk
negotiated protocolVersion: 2025-11-25
server capability: tools: true
server capability: resources: true
server capability: prompts: true
server capability: logging: true
client declared: sampling: true
client declared: elicitation: true
Two things are worth reading off this. The client has to declare sampling and elicitation up front; a server cannot assume them. And the negotiated protocol version is 2025-11-25, which matters in the last section.
Intermediate: what the handshake actually exchanges
Every MCP session starts with initialize: the client sends the protocol versions and capabilities it supports, the server replies with its own, and the client then sends notifications/initialized. In the Java SDK the capability flags you see above come straight from that exchange. The server-side checks you will use later, sampleEnabled() and elicitEnabled() in Spring AI’s request context, read the capabilities the client declared here.
The SDK’s own constants class lists protocol versions up to 2025-11-25 and no later one (checked with javap on mcp-core-2.1.0.jar), which agrees with the negotiated version in the output.
Going deeper
- The test client and transcript writer: McpAdvancedTest.java, Transcript.java
- To rerun everything: run-all.sh
Resources: data the app attaches, addressed by a URI
A resource is read-only data with a URI. A fixed resource has one URI and shows up in the resource list. A template has a placeholder in the URI, likeincident://{id}/log, and the client fills it in. In the incident desk there is one fixed resource (the list of incident ids) and two templates (an incident’s raw log, and a service’s runbook).
The fixed resource and one template, from IncidentResources.java:
@McpResource(uri = "incident://index", name = "incident-index",
description = "Ids of all open incidents", mimeType = "text/plain")
public ReadResourceResult index() {
return new ReadResourceResult(List.of(
new TextResourceContents("incident://index", "text/plain",
String.join("\n", data.incidentIds()))));
}
/** A resource template: {id} is filled from the URI the client asks for. */
@McpResource(uri = "incident://{id}/log", name = "incident-log",
description = "Raw log lines for one incident", mimeType = "text/plain")
public ReadResourceResult log(String id) {
String log = data.log(id);
if (log == null) {
throw new IllegalArgumentException("No such incident: " + id);
}
return new ReadResourceResult(List.of(
new TextResourceContents("incident://" + id + "/log", "text/plain", log)));
}
The test lists them and reads them. Output from 02-resources.txt:
# resources/list, resources/templates/list, resources/read
resource incident://index name=incident-index mime=text/plain
template incident://{id}/log name=incident-log
template runbook://{service} name=runbook
read incident://index ->
INC-1042
INC-1043
read incident://INC-1043/log ->
11:02:44 search-api WARN index segment merge running
11:02:51 search-api INFO p99 latency 840ms (SLO 500ms)
read incident://INC-9999/log -> McpError: Error invoking resource method: log in com.ankurm.mcpadvanced.IncidentResources. /nCause: No such incident: INC-9999
Notice that resources/list returns only the fixed resource; the templates come from a separate call, resources/templates/list. A client that only calls the first will never see your templates.
Two small surprises in that last line. A missing incident reaches the client as aMcpErrorwhose message contains a literal/nCause, a backslash-n that was meant to be a newline and was not turned into one; that is what Spring AI 2.0.2 produced, not something I added. And the order of the template list is not fixed: the test sorts the lines before writing the transcript so reruns diff cleanly. Do not depend on list order.
Intermediate: tool or resource? A short decision rule
Ask who should trigger the read. If the model should decide whether it needs the data (“look up order 42 if the customer asks”), make it a tool. If the application or user decides (“attach this runbook to the conversation”), make it a resource. If the data depends on arguments the model must work out, a resource template can still fit, but only when the client UI lets a person or app supply those arguments.
| Question | Tool | Resource |
|---|---|---|
| Who triggers it? | The model | The app or user |
| Can it change state? | Yes | No, read-only by intent |
| Does it need to be discoverable by the model alone? | Yes | Only if the client exposes it |
Going deeper
- IncidentResources.java — all three resources
- IncidentData.java — the fixed data behind them
- Spec: resources, templates and subscriptions
Prompts: a template the user picks by name
A prompt is a named, parameterised template. The server fills in the arguments and returns finished messages; the client shows the prompt in a menu and, when the user picks it, sends those messages to the model. The server does not call the model here at all.The postmortem prompt, from IncidentPrompts.java:
@McpPrompt(name = "postmortem", description = "Draft a blameless postmortem for an incident")
public GetPromptResult postmortem(
@McpArg(name = "incidentId", description = "e.g. INC-1042", required = true) String incidentId,
@McpArg(name = "audience", description = "who will read it", required = false) String audience) {
String log = data.log(incidentId);
if (log == null) {
throw new IllegalArgumentException("No such incident: " + incidentId);
}
String who = (audience == null || audience.isBlank()) ? "the engineering team" : audience;
String text = "Write a blameless postmortem for " + who + ". Sections: impact, timeline, "
+ "root cause, follow-ups. Use only the log below.\n\n" + log;
return new GetPromptResult("Postmortem for " + incidentId,
List.of(new PromptMessage(Role.USER, new TextContent(text))));
}
Output from 03-prompts.txt:
# prompts/list and prompts/get
prompt postmortem -- Draft a blameless postmortem for an incident
arg incidentId required=true
arg audience required=false
get postmortem(INC-1043, on-call manager) ->
[USER] Write a blameless postmortem for the on-call manager. Sections: impact, timeline, root cause, follow-ups. Use only the log below.
11:02:44 search-api WARN index segment merge running
11:02:51 search-api INFO p99 latency 840ms (SLO 500ms)
get postmortem(INC-9999) -> McpError: Error invoking prompt method: postmortem in com.ankurm.mcpadvanced.IncidentPrompts./nCause: No such incident: INC-9999
The log lines are pulled into the prompt text by the server, so the user never pastes anything. A required argument that points at nothing (INC-9999) comes back as an error rather than an empty prompt, which is what you want: a bad prompt that sends silently is worse than a failed one.
Reference: argument completion
The annotations jar for Spring AI 2.0.2 also contains an @McpComplete annotation, which is how a server offers auto-complete suggestions for prompt arguments and resource-template placeholders. I confirmed the annotation exists in the jar; I did not run it, so this post makes no claim about its behaviour.
Going deeper
Sampling: the server borrows the client’s model
Suppose a tool needs a summary and you do not want to put an LLM API key on the server. Sampling lets the tool say “client, please run this prompt through your model and give me the text back.” The user’s own model, with the user’s own credentials and cost limits, does the work.sampling/createMessage request, the client runs its model and answers with a separate HTTP POST, and only then can the server finish the tool.
The tool, from IncidentTools.java. The capability check near the top is not decoration; section “When the client cannot do it” shows why:
/** Sampling: the server borrows the client's model instead of owning an API key. */
@McpTool(name = "summarize_incident", description = "Summarize an incident log in two sentences")
public String summarize(@McpToolParam(description = "e.g. INC-1042", required = true) String incidentId,
McpSyncRequestContext ctx) {
String log = data.log(incidentId);
if (log == null) {
return "No such incident: " + incidentId;
}
// Capability gate: the client only gets sampling if it declared it during initialize.
if (!ctx.sampleEnabled()) {
return "SAMPLING_UNAVAILABLE -- client has no model to lend. Raw log:\n" + log;
}
CreateMessageResult result = ctx.sample(spec -> spec
.systemPrompt("You are an SRE. Answer in two sentences, no preamble.")
.message("Summarize this incident log:\n" + log)
.maxTokens(120)
.temperature(0.2));
return ((TextContent) result.content()).text();
}
The test client plays the model with a scripted answer and records what the server sent it. Output from 04-sampling.txt:
# tools/call summarize_incident -> server sends sampling/createMessage to the client
what the CLIENT received from the server:
systemPrompt: You are an SRE. Answer in two sentences, no preamble.
messages: 1
messages[0].role: USER
messages[0] starts with: Summarize this incident log:
maxTokens: 120
temperature: 0.2
includeContext: NONE
tool result returned to the caller:
Connection pool on payments-api was exhausted by a 14.8s ledger query, so 212 clients got HTTP 503. Fix the query or raise the pool.
isError: false
The client received exactly what the tool asked for: the system prompt, one user message, 120 max tokens, temperature 0.2. includeContext is NONE, the default, which means the server is not asking the client to add any other MCP server’s context.
To see this as protocol rather than as Java objects, wire-demo.sh runs the real jar and plays the client with curl. The server’s request, copied from 10-wire-sampling.txt (the id changes on every run):
{
"jsonrpc": "2.0",
"method": "sampling/createMessage",
"id": "7899c995-7e19-45a9-8b27-a043acabe802-0",
"params": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Summarize this incident log:\n11:02:44 search-api WARN index segment merge running\n11:02:51 search-api INFO p99 latency 840ms (SLO 500ms)\n"
}
}
],
"systemPrompt": "You are an SRE. Answer in two sentences, no preamble.",
"includeContext": "none",
"temperature": 0.2,
"maxTokens": 120
}
}
The request id is generated by the server (a per-session UUID plus a counter, as the two wire transcripts show). The client answers with a new POST whose JSON-RPC id equals it; that is how the server matches the answer to the question.
Intermediate: model preferences, human review, and what the client may ignore
The request above leaves out modelPreferences. The spec lets a server pass hints (model-name substrings) and three priorities between 0 and 1 for cost, speed and intelligence. These are advisory; the client makes the final model choice and may map a hint to a different provider’s equivalent.
The spec also says there SHOULD be a human in the loop who can deny a sampling request, and that clients SHOULD let users review the prompt and the response. A scripted handler like the one in this test skips that; a real client will not. Design the tool so a denied request is a normal outcome, not an exception.
The includeContext values thisServer and allServers are marked soft-deprecated in the 2025-11-25 spec, which tells servers to omit the field instead (it defaults to none, as in the output above). The same spec revision also describes sampling with tools, which a client must declare separately; this post does not use it.
Reference: the same call in the plain Java SDK
Spring AI’s McpSyncRequestContext is a convenience over the SDK’s exchange object. In the test, the client side uses the SDK directly: McpClient.sync(transport).capabilities(...).sampling(handler).build(), where the handler is a Function<CreateMessageRequest, CreateMessageResult>. See McpAdvancedTest.java for the exact calls.
Going deeper
- IncidentTools.java — the sampling tool
- 10-wire-sampling.txt — the four messages in full
- Spec: sampling
Elicitation: stop the tool and ask the human
Elicitation is the same shape as sampling, but the question goes to the person instead of the model. The server describes a small form; the client shows it; the person accepts, declines or cancels. It is the right tool for “I need a confirmation before I do something destructive” and for “I am missing one parameter.”The form is a plain record, RestartApproval.java. Spring AI turns it into a JSON schema:
public record RestartApproval(
@JsonPropertyDescription("Tick to approve the restart") boolean confirm,
@JsonPropertyDescription("Change ticket number, e.g. CHG-2291") String ticket) {
}
And the tool that uses it, from IncidentTools.java:
/** Elicitation: pause the tool and ask the HUMAN a structured question. */
@McpTool(name = "restart_service", description = "Restart a service after the operator approves")
public String restart(@McpToolParam(description = "service name, e.g. payments-api", required = true) String service,
McpSyncRequestContext ctx) {
if (!ctx.elicitEnabled()) {
return "ELICITATION_UNAVAILABLE -- refusing to restart " + service + " without an approval channel";
}
StructuredElicitResult<RestartApproval> answer = ctx.elicit(
spec -> spec.message("Restart " + service + "? This drops in-flight requests."),
RestartApproval.class);
return switch (answer.action()) {
case ACCEPT -> answer.structuredContent().confirm()
? "RESTARTED " + service + " under " + answer.structuredContent().ticket()
: "NOT RESTARTED -- form submitted with the box unticked";
case DECLINE -> "NOT RESTARTED -- operator declined";
case CANCEL -> "NOT RESTARTED -- operator dismissed the dialog";
};
}
switch has no default branch on purpose; if a fourth action were ever added, the compiler would flag this method.
The test answers the form four ways. Output from 05-elicitation.txt:
# tools/call restart_service -> server sends elicitation/create to the client
message shown to the human: Restart payments-api? This drops in-flight requests.
requestedSchema sent by the server: {type=object, properties={confirm={type=boolean, description=Tick to approve the restart}, ticket={type=string, description=Change ticket number, e.g. CHG-2291}}, required=[confirm, ticket]}
ACCEPT confirm=true ticket=CHG-2291: RESTARTED payments-api under CHG-2291
ACCEPT confirm=false: NOT RESTARTED -- form submitted with the box unticked
DECLINE: NOT RESTARTED -- operator declined
CANCEL : NOT RESTARTED -- operator dismissed the dialog
Every record component became required. Look atrequired=[confirm, ticket]in the schema the server sent. The booleanconfirmis required, which is fine, but so is the textticket, so a person who unticks the box still has to type something. If you want an optional field, the record is not enough on its own; I did not test which annotation changes this, so check before relying on it.
The raw request, from 11-wire-elicitation.txt. Note "mode": "form":
{
"jsonrpc": "2.0",
"method": "elicitation/create",
"id": "7899c995-7e19-45a9-8b27-a043acabe802-1",
"params": {
"message": "Restart payments-api? This drops in-flight requests.",
"requestedSchema": {
"type": "object",
"properties": {
"confirm": {
"type": "boolean",
"description": "Tick to approve the restart"
},
"ticket": {
"type": "string",
"description": "Change ticket number, e.g. CHG-2291"
}
},
"required": [
"confirm",
"ticket"
]
},
"_meta": {},
"mode": "form"
}
}
Intermediate: what a form may contain, and what it must never ask for
The 2025-11-25 spec limits the requested schema to a flat object whose properties are strings, numbers, integers, booleans or enums (single or multi-select). No nested objects and no arrays of objects. That is why RestartApproval is flat; a nested record would not be something a client is required to render.
Form mode must not be used to collect passwords, API keys, access tokens or payment credentials. For those the spec defines a second mode, URL mode, where the client sends the person to an external page and the secret never passes through the client. This post covers form mode only.
The spec also has a rule about identity: servers must not trust a user identity typed into a form. If your approval needs to know who approved, take that from authentication, not from a field. The Spring Security MCP post covers the authenticated side.
Going deeper
- RestartApproval.java
- 11-wire-elicitation.txt — the four messages in full
- Spec: elicitation (form mode, URL mode, security rules)
When the client cannot do it: what actually happens
Both features depend on the client having declared them. The failure is not subtle, but it is easy to build a tool that only works against your own test client.A client that declared neither capability calls the three tools. Output from 06-no-capabilities.txt:
# a client that declared neither sampling nor elicitation
summarize_incident (gated with sampleEnabled()):
SAMPLING_UNAVAILABLE -- client has no model to lend. Raw log:
11:02:44 search-api WARN index segment merge running
11:02:51 search-api INFO p99 latency 840ms (SLO 500ms)
restart_service (gated with elicitEnabled()):
ELICITATION_UNAVAILABLE -- refusing to restart payments-api without an approval channel
summarize_incident_ungated (calls sample() with no check):
result: Sampling not supported by the client: Implementation[name=Java SDK MCP Client, title=null, version=0.15.0, description=null, icons=null, websiteUrl=null]
Sampling not supported by the client: Implementation[name=Java SDK MCP Client, title=null, version=0.15.0, description=null, icons=null, websiteUrl=null]
isError: true
The two gated tools degrade by design: one returns the raw log, the other refuses to restart anything. The ungated tool called sample() without checking, and the result is a tool result with isError=true and the reason as text, not a protocol failure. Note that the message appears twice, the same duplication I documented in the Spring Security MCP post, so do not read it as two separate errors.
A slow client is a failing server. The server waits for the client’s answer only as long as spring.ai.mcp.server.request-timeout (the default is 20 seconds). The next test sets it to 3 seconds and makes the client’s model take 5.
Output from 07-slow-client-timeout.txt:
# client model takes 5s, server spring.ai.mcp.server.request-timeout=3s
outcome: result isError=true text=java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 3000ms in 'source(MonoCreate)' (and no fallback has been configured)
Did not observe any item or terminal signal within 3000ms in 'source(MonoCreate)' (and no fallback has been configured)
returned in about (seconds, rounded): 3
(timing is indicative, not a result)
The tool call comes back after about three seconds with isError=true and a Reactor TimeoutException in the text. For an elicitation the same clock is running while a human reads a dialog, so a real approval flow needs a timeout longer than someone going for coffee, and a tool that treats a timeout as “no”.
The silent one: a stateless server drops the tools
Spring AI can run the server in a stateless mode, which is attractive because it needs no session affinity behind a load balancer. The test class McpStatelessTest.java restarts the same application withspring.ai.mcp.server.protocol=STATELESS and a client that declares both capabilities.
Output from 08-stateless-server.txt:
# spring.ai.mcp.server.protocol=STATELESS, client declares sampling + elicitation
tools listed: []
summarize_incident: exception McpError: Unknown tool: invalid_tool_name
restart_service: exception McpError: Unknown tool: invalid_tool_name
times the client's handlers were called: 0
The only trace on the server is a WARN line at startup, one per skipped method, captured in 09-stateless-warning.txt:
# log line from the STATELESS test run (spring.ai.mcp.server.protocol=STATELESS)
McpPredicates : Stateless servers doesn't support bidirectional parameters. Skipping method public java.lang.String com.ankurm.mcpadvanced.IncidentTools.restart(java.lang.String,org.springframework.ai.mcp.annotation.context.McpSyncRequestContext) with bidirectional parameters
This is the fingerprint of the bug. Every tool takes aMcpSyncRequestContext, so all three vanished,tools/listis empty, and calling a tool by name saysUnknown tool, not “this tool needs a session”. If your server works locally and shows no tools after you switch to stateless, grep the startup log forStateless servers.
Intermediate: why a stateless server cannot do this
Sampling and elicitation are requests from the server to the client. In the wire transcripts above the request arrives on the stream of the tools/call that is still open and carries a session-scoped id. A stateless server has no session to hang that on, so Spring AI refuses the method at registration, with the WARN above, instead of failing on the first call. The WARN text says the skip is about “bidirectional parameters”, so I expect a tool without a context object to register normally; I did not test that, because every tool in this module takes one.
Going deeper
What changes in 2026-07-28
Read, not run. Everything in this section comes from the published spec pages and the MCP blog, linked at the end. The one thing I ran is that SDK 2.1.0 negotiates 2025-11-25. I have not tested any Java SDK build that speaks 2026-07-28.
The 2026-07-28 revision removes the handshake and protocol-level sessions, so every request is self-contained. That has a direct consequence for this post: a server that can no longer hold a session cannot send a request down a held-open stream.
sampling/createMessage or elicitation/create mid-call, the server finishes the call with an input_required result that lists what it needs; the client gathers the answers and retries the original call with them. The spec page says servers must use this pattern for those requests and that the older server-initiated pattern is no longer supported.
Three consequences for code like the above. First, the idea of elicitation survives, but the Java you wrote against McpSyncRequestContext.elicit(...) will need the SDK version that implements the new pattern. Second, the changelog deprecates Roots, Sampling and Logging; for sampling its suggested migration is to call an LLM provider directly instead. So do not start new work that depends on sampling. Third, the changelog removes the notifications/elicitation/complete notification introduced in 2025-11-25.
Reference: what I could and could not confirm
Confirmed from the spec pages: the MRTR pattern and the input_required result type, the deprecation of Roots, Sampling and Logging (SEP-2577), and the removal of the elicitation-complete notification. The blog post calls the release “No handshake or sessions” as a heading.
Not confirmed: when a Spring AI release that implements 2026-07-28 will exist, and how its annotations will look. I did not find that out, so the advice here is limited to what is stable: keep sampling behind a capability check, keep elicitation answers as a normal outcome, and keep the tool’s logic separate from how the question reaches the user.
Going deeper
- Spec 2026-07-28: key changes
- Spec 2026-07-28: Multi Round-Trip Requests
- MCP blog: The 2026-07-28 Specification
Should you build on these four?
Mostly yes for two, carefully for one, no for one. Resources and prompts are cheap to add and harmless when a client ignores them, as long as every important capability also exists as a tool. Elicitation is the right way to get a human’s approval, if the tool refuses to act when no approval channel exists (asrestart_servicedoes) and treats decline, cancel and timeout as “no”. Sampling works today on a stateful server, but it is deprecated in the newest spec, so for anything new call a model provider from the server instead.
And if your deployment is stateless, checktools/listfirst: it is the only way you will notice the callback tools are gone.
Further reading
- Companion module: mcp-advanced — run
scripts/run-all.shto regenerate every output file quoted above - Build an MCP Server with Spring AI 2.0
- Spring AI MCP Client: Calling External MCP Servers from ChatClient
- Securing an MCP Server with Spring Security 7
- Write an MCP Server with the Plain MCP Java SDK
- Spring AI reference: MCP annotations
- MCP spec 2025-11-25: sampling
- MCP spec 2025-11-25: elicitation
- MCP spec 2026-07-28: Multi Round-Trip Requests
No Comments yet!