Skip to main content

MCP Beyond Tools in Java: Resources, Prompts, Sampling and Elicitation

Most MCP servers stop at tools. This post builds an incident-desk server in Java with resources, a prompt, sampling and elicitation on Spring AI 2.0.2 and MCP SDK 2.1.0, then breaks it on purpose: a client without the capability, a slow client, a stateless server that silently drops every tool, and what the 2026-07-28 spec changes.

Most MCP servers I see expose a pile of tools and stop there. That works until you want the server to do one of four things tools cannot: hand the application a file or a log to look at, offer the user a ready-made prompt, ask the model on the other side to write a summary, or stop and ask a human “are you sure?” before it restarts something. The protocol has a feature for each of those, and they are the half of MCP that most tutorials skip. This post builds one small server, an “incident desk”, that uses all four, then breaks it on purpose so you can see what each failure looks like. Everything below was run; the output blocks are copied from files committed in the mcp-advanced module of the companion repository. If you have not built an MCP server before, the Spring AI MCP server post and the plain-SDK post cover tools. You do not need them to follow this one.
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 against maven-metadata.xml), JDK Temurin 25.0.5+8. The SDK negotiates MCP protocol version 2025-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.
Who decides when each feature is used Toolsthe MODEL decides to call onedo something, return a result Promptsthe USER picks onea reusable template, like /command Resourcesthe APPLICATION attaches oneread-only data, addressed by URI Sampling (server asks back)“run this through YOUR model”no API key on the server Elicitation (server asks back)“ask the HUMAN this, mid-call”approvals, missing parameters The top row is the server answering. The dashed row is the server asking, and it can only ask while a request is open.
The top row of the diagram is the server answering; the dashed row is the server asking. Keep that distinction, because the second row has a rule the first does not: the server can only ask while the client has a request open. Every failure later in this post follows from that rule.
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

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

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, like incident://{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 a McpError whose 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.

QuestionToolResource
Who triggers it?The modelThe app or user
Can it change state?YesNo, read-only by intent
Does it need to be discoverable by the model alone?YesOnly if the client exposes it

Going deeper

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.
One tool call that borrows the client’s model ClientServer 1 tools/call summarize_incident 2 sampling/createMessage client runs its own model 3 new POST: the model’s answer 4 result of the original tools/call the tools/call from step 1is still open the whole time Steps 2 and 3 only exist inside steps 1 and 4. That nesting is the whole design.
Read the diagram top to bottom. The tool call in step 1 does not return until step 4. Steps 2 and 3 happen inside it: the server sends a 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

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";
        };
    }
What the tool does with each answer elicitation/create“Restart payments-api?” ACCEPT + data DECLINE CANCEL confirm=true → RESTARTED … under CHG-2291 confirm=false → NOT RESTARTED (box unticked) NOT RESTARTED (operator declined) NOT RESTARTED (dialog dismissed) Four outcomes, one restart. The default for anything unexpected must be “do nothing”.
The picture is the design: there are three answers from the human (accept, decline, cancel), accept carries data that can itself say no, and only one combination restarts anything. The 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 at required=[confirm, ticket] in the schema the server sent. The boolean confirm is required, which is fine, but so is the text ticket, 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

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 with spring.ai.mcp.server.protocol=STATELESS and a client that declares both capabilities.
Same server code, two protocol settings protocol=STREAMABLE (default) session exists, so the server has astream it can send requests down summarize_incident registeredrestart_service registeredsampling + elicitation work (04, 05) protocol=STATELESS no session, nothing to send a request down WARN Skipping method …summarizeWARN Skipping method …restarttools/list → []tools/call → Unknown tool (08, 09) No exception, no failed startup: the tools are simply not there.

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 a McpSyncRequestContext, so all three vanished, tools/list is empty, and calling a tool by name says Unknown 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 for Stateless 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.
The same question, asked two ways 2025-11-25 (what you just ran) C → S tools/callS → C sampling/createMessageC → S (answer, new POST)S → C tools/call result The first call stays open while theserver waits. The session ties it together. 2026-07-28 (multi round-trip) C → S tools/callS → C input_required + inputRequestsC → S tools/call again + answersS → C result (complete) Nothing is held open. Any server instancecan handle either call. Right-hand panel is from the published spec pages, not from a run: SDK 2.1.0 does not speak it.
The spec’s answer is “multi round-trip requests”. Instead of sending 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

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 (as restart_service does) 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, check tools/list first: it is the only way you will notice the callback tools are gone.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.