Add mcp-advanced module: resources, prompts, sampling and elicitation on Spring AI 2.0.2 and MCP Java SDK 2.1.0

An incident-desk MCP server with a fixed resource, two resource templates, a prompt, a tool that borrows the client's model (sampling) and a tool that asks the operator to approve a restart (elicitation). Driven by a plain SDK client in JUnit and by raw curl, so the JSON-RPC for both server-to-client requests is committed. Also captured: a client that declared neither capability (a gated tool degrades, an ungated one returns an isError result), a slow client hitting the server request timeout, and protocol=STATELESS silently dropping every tool that takes McpSyncRequestContext (one WARN line, an empty tools/list). Negotiated protocol version is 2025-11-25; the 2026-07-28 spec is not implemented by SDK 2.1.0. No docs/ folder.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b5jAFLJrmUXmbo5EuV
This commit is contained in:
2026-11-10 18:47:09 +00:00
co-authored by Claude Sonnet 5.5
parent 68da642f9a
commit 0006167c24
27 changed files with 1033 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur
| [`mcp-server/`](mcp-server) | An order-lookup service exposed as MCP tools, a resource, and a prompt with `@McpTool`/`@McpResource`/`@McpPrompt`, served over Streamable HTTP (Spring AI 2.0's default MCP server transport). Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Build an MCP Server with Spring AI 2.0](https://ankurm.com/spring-ai-2-0-mcp-server-streamable-http/) |
| [`mcp-client/`](mcp-client) | `ChatClient` calling tools from two real external MCP servers (filesystem, git) over stdio via `defaultToolCallbacks(ToolCallbackProvider...)`, contrasted with a local `@Tool` method, with every call logged through one Micrometer `ObservationHandler`. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Spring AI MCP Client: Calling External MCP Servers from ChatClient](https://ankurm.com/spring-ai-2-0-mcp-client/) |
| [`mcp-secure/`](mcp-secure) | The mcp-server article's order-lookup tools behind a real OAuth2 resource server: JWT validation, one scope per tool via `@PreAuthorize`, unauthenticated tool discovery rejected outright, and every call audit-logged through MDC -- denials included. Spring Boot 4.1.1, Spring AI 2.0.1, Spring Security 7.1.1, Java 25. | [Securing an MCP Server with Spring Security 7](https://ankurm.com/spring-ai-2-0-mcp-server-security/) |
| [`mcp-advanced/`](mcp-advanced) | An incident-desk MCP server using resources (fixed and templated), a prompt, sampling and elicitation, driven by a plain MCP SDK client and by raw curl so the JSON-RPC is visible; includes what a client without those capabilities sees, a slow client hitting the server timeout, and the STATELESS protocol mode silently dropping every tool that needs a callback. Spring Boot 4.1.2, Spring AI 2.0.2, MCP Java SDK 2.1.0, Java 25. | [MCP Beyond Tools in Java: Resources, Prompts, Sampling and Elicitation](https://ankurm.com/mcp-resources-prompts-sampling-elicitation-java/) |
| [`tool-calling/`](tool-calling) | `@Tool` methods, `ToolCallingAdvisor` (the advisor-layer replacement for Spring AI 1.x's per-model tool loop), `returnDirect`, `ToolContext`, and `ToolSearchToolCallingAdvisor` for progressive disclosure across a 230-tool synthetic library -- every test driven by a hand-written `ScriptedChatModel`, no live model anywhere. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Tool Calling in Spring AI 2.0](https://ankurm.com/spring-ai-2-0-tool-calling/) |
| [`structured-output/`](structured-output) | `ChatClient.entity()` mapping LLM responses to Java records, lists and maps; `StructuredOutputValidationAdvisor` retrying non-conforming JSON with a real enum-constrained schema, including a captured run that exhausts every retry without throwing. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Structured Output in Spring AI 2.0](https://ankurm.com/spring-ai-2-0-structured-output/) |
| [`ollama-local/`](ollama-local) | Chat and embeddings against a real local `qwen2.5:0.5b`/`all-minilm`, no API key, driven by a Testcontainers-managed Ollama container started from a baked image; a confirmed model unload via `keep_alive: 0` and `/api/ps`, not a scripted model anywhere. Spring Boot 4.1.1, Spring AI 2.0.1, Testcontainers 2.0.5, Java 25. | [Run LLMs Locally with Spring AI and Ollama](https://ankurm.com/spring-ai-2-0-ollama-local/) |
+1
View File
@@ -0,0 +1 @@
target/
+21
View File
@@ -0,0 +1,21 @@
# mcp-advanced
The half of MCP beyond tools: **resources**, **prompts**, **sampling** and **elicitation**, on Spring Boot 4.1.2, Spring AI 2.0.2 and MCP Java SDK 2.1.0 (Java 25). The server is an "incident desk"; the tests drive it with a plain MCP SDK client so the transcripts show what is on the wire.
| Piece | File |
|---|---|
| Resources + a template | `src/main/java/com/ankurm/mcpadvanced/IncidentResources.java` |
| Prompt | `.../IncidentPrompts.java` |
| Tools that call back into the client (sampling, elicitation) | `.../IncidentTools.java`, `.../RestartApproval.java` |
| Test client + transcript writer | `src/test/java/com/ankurm/mcpadvanced/` |
## Run
```bash
./scripts/run-all.sh # tests (they write output/01..09) + curl wire demo (output/10, 11)
mvn spring-boot:run # the server on :8089/mcp
```
`output/` holds the captured results the article quotes. Request and session ids in the two wire transcripts change on every run; the one timing line in `07` is indicative only.
There is deliberately no `docs/` folder: the explanation lives in the article, in collapsible sections next to the code it describes.
+9
View File
@@ -0,0 +1,9 @@
# 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
+13
View File
@@ -0,0 +1,13 @@
# 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
+13
View File
@@ -0,0 +1,13 @@
# 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
+13
View File
@@ -0,0 +1,13 @@
# 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
+8
View File
@@ -0,0 +1,8 @@
# 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
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,5 @@
# 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)
@@ -0,0 +1,5 @@
# 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
@@ -0,0 +1,4 @@
# 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
McpPredicates : Stateless servers doesn't support bidirectional parameters. Skipping method public java.lang.String com.ankurm.mcpadvanced.IncidentTools.summarize(java.lang.String,org.springframework.ai.mcp.annotation.context.McpSyncRequestContext) with bidirectional parameters
McpPredicates : Stateless servers doesn't support bidirectional parameters. Skipping method public java.lang.String com.ankurm.mcpadvanced.IncidentTools.summarizeUngated(java.lang.String,org.springframework.ai.mcp.annotation.context.McpSyncRequestContext) with bidirectional parameters
+56
View File
@@ -0,0 +1,56 @@
# sampling/createMessage on the wire
## 1. client -> server (POST, answers arrive later on the same session)
tools/call summarize_incident {"incidentId":"INC-1043"}
## 2. server -> client, on the SSE stream of that same POST
{
"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
}
}
## 3. client -> server, a NEW POST carrying the answer (same id as the server's request)
{
"jsonrpc": "2.0",
"id": "7899c995-7e19-45a9-8b27-a043acabe802-0",
"result": {
"role": "assistant",
"content": {
"type": "text",
"text": "A segment merge pushed p99 to 840ms against a 500ms SLO."
},
"model": "curl-model",
"stopReason": "endTurn"
}
}
## 4. server -> client, the original tools/call finally completes
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{
"type": "text",
"text": "A segment merge pushed p99 to 840ms against a 500ms SLO."
}
],
"isError": false
}
}
@@ -0,0 +1,61 @@
# elicitation/create on the wire
## 1. client -> server (POST, answers arrive later on the same session)
tools/call restart_service {"service":"payments-api"}
## 2. server -> client, on the SSE stream of that same POST
{
"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"
}
}
## 3. client -> server, a NEW POST carrying the answer (same id as the server's request)
{
"jsonrpc": "2.0",
"id": "7899c995-7e19-45a9-8b27-a043acabe802-1",
"result": {
"action": "accept",
"content": {
"confirm": true,
"ticket": "CHG-2291"
}
}
}
## 4. server -> client, the original tools/call finally completes
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{
"type": "text",
"text": "RESTARTED payments-api under CHG-2291"
}
],
"isError": false
}
}
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>mcp-advanced</artifactId>
<version>1.0.0</version>
<name>mcp-advanced</name>
<description>The half of MCP beyond tools: resources, prompts, sampling and elicitation, on Spring AI 2.0.2 and MCP Java SDK 2.1.0</description>
<properties>
<java.version>25</java.version>
<!-- Spring AI is not managed by the Spring Boot BOM: this pair is yours to keep compatible. -->
<spring-ai.version>2.0.2</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Regenerates every file in output/. Needs JDK 25, Maven, curl, python3.
# Session ids and request ids in the wire transcripts differ on every run; everything else is stable
# except the one timing line in 07, which is indicative only.
set -eu
cd "$(dirname "$0")/.."
# 1-8: the JUnit tests write output/01..08 themselves (the assertions and the transcripts are the same code)
mvn -B -q test 2>&1 | tee /tmp/mcp-advanced-test.log | grep -E 'Tests run:|BUILD|ERROR' || true
# 9: the one WARN the stateless server logs while it silently drops the tools
{
echo "# log line from the STATELESS test run (spring.ai.mcp.server.protocol=STATELESS)"
grep 'Stateless servers' /tmp/mcp-advanced-test.log | sed -E 's/^.*(McpPredicates)/\1/'
} > output/09-stateless-warning.txt
# 10-11: raw JSON-RPC with curl as the client
mvn -B -q package -DskipTests
bash scripts/wire-demo.sh
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Shows the sampling and elicitation messages as raw JSON-RPC over Streamable HTTP, using curl as the
# "client". Needs: target/mcp-advanced-1.0.0.jar (mvn package -DskipTests), JDK 25 on PATH, curl, python3.
# Output goes to output/10-wire-sampling.txt and output/11-wire-elicitation.txt.
set -u
cd "$(dirname "$0")/.."
URL=http://localhost:8089/mcp
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
OUT=output
TMP=$(mktemp -d)
setsid nohup java -Dserver.port=8089 -jar target/mcp-advanced-1.0.0.jar > "$TMP/app.log" 2>&1 < /dev/null &
APP_PID=$!
for i in $(seq 1 60); do
curl -s --noproxy '*' -o /dev/null -X POST "$URL" "${H[@]}" -d '{}' && break
sleep 1
done
# --- initialize, declaring BOTH sampling and elicitation ---------------------------------------
curl -s --noproxy '*' -D "$TMP/init.h" -o "$TMP/init.b" -X POST "$URL" "${H[@]}" -d '{
"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-11-25",
"capabilities":{"sampling":{},"elicitation":{}},
"clientInfo":{"name":"curl-client","version":"0"}}}'
SID=$(grep -i '^mcp-session-id:' "$TMP/init.h" | awk '{print $2}' | tr -d '\r')
curl -s --noproxy '*' -o /dev/null -X POST "$URL" "${H[@]}" -H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# Run one tool call, read the server's request out of the SSE stream, answer it, then show the final result.
roundtrip () { # $1 = tool name $2 = arguments json $3 = answer result json $4 = output file $5 = title
local call_id=7
curl -s --noproxy '*' -N -X POST "$URL" "${H[@]}" -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":$call_id,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}" \
> "$TMP/stream.txt" &
local cpid=$!
for i in $(seq 1 40); do grep -q '"method":"\(sampling/createMessage\|elicitation/create\)"' "$TMP/stream.txt" 2>/dev/null && break; sleep 0.25; done
local req_line req_id
req_line=$(grep '^data:' "$TMP/stream.txt" | grep -E 'sampling/createMessage|elicitation/create' | head -1 | sed 's/^data: *//')
req_id=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["id"])' "$req_line")
curl -s --noproxy '*' -o /dev/null -X POST "$URL" "${H[@]}" -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":\"$req_id\",\"result\":$3}"
wait $cpid
{
echo "# $5"
echo
echo "## 1. client -> server (POST, answers arrive later on the same session)"
echo "tools/call $1 $2"
echo
echo "## 2. server -> client, on the SSE stream of that same POST"
python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1]),indent=2))' "$req_line"
echo
echo "## 3. client -> server, a NEW POST carrying the answer (same id as the server's request)"
python3 -c 'import json,sys; print(json.dumps({"jsonrpc":"2.0","id":sys.argv[1],"result":json.loads(sys.argv[2])},indent=2))' "$req_id" "$3"
echo
echo "## 4. server -> client, the original tools/call finally completes"
grep '^data:' "$TMP/stream.txt" | tail -1 | sed 's/^data: *//' | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin),indent=2))'
} > "$OUT/$4"
}
roundtrip summarize_incident '{"incidentId":"INC-1043"}' \
'{"role":"assistant","content":{"type":"text","text":"A segment merge pushed p99 to 840ms against a 500ms SLO."},"model":"curl-model","stopReason":"endTurn"}' \
10-wire-sampling.txt "sampling/createMessage on the wire"
roundtrip restart_service '{"service":"payments-api"}' \
'{"action":"accept","content":{"confirm":true,"ticket":"CHG-2291"}}' \
11-wire-elicitation.txt "elicitation/create on the wire"
kill "$APP_PID" 2>/dev/null
rm -rf "$TMP"
@@ -0,0 +1,40 @@
package com.ankurm.mcpadvanced;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/** Fixed in-memory data so every run of the demo reads the same bytes. */
@Component
public class IncidentData {
private final Map<String, String> logs = Map.of(
"INC-1042", """
09:14:02 payments-api WARN connection pool at 48/50
09:14:09 payments-api ERROR timeout acquiring connection after 30000ms
09:14:09 payments-api ERROR timeout acquiring connection after 30000ms
09:14:31 payments-api ERROR HTTP 503 returned to 212 clients
09:15:02 payments-db WARN slow query 14.8s: SELECT * FROM ledger WHERE account_id = ?
""",
"INC-1043", """
11:02:44 search-api WARN index segment merge running
11:02:51 search-api INFO p99 latency 840ms (SLO 500ms)
""");
private final Map<String, String> runbooks = Map.of(
"payments", "1. Check pool metrics. 2. Look for slow queries on payments-db. 3. Restart payments-api only after approval.",
"search", "1. Check segment merges. 2. Scale read replicas. 3. Restart is safe at any time.");
public String log(String incidentId) {
return logs.get(incidentId);
}
public String runbook(String service) {
return runbooks.get(service);
}
public List<String> incidentIds() {
return logs.keySet().stream().sorted().toList();
}
}
@@ -0,0 +1,42 @@
package com.ankurm.mcpadvanced;
import java.util.List;
import org.springframework.ai.mcp.annotation.McpArg;
import org.springframework.ai.mcp.annotation.McpPrompt;
import org.springframework.stereotype.Component;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
/**
* A prompt is a template the USER picks (think slash command). The server fills in the
* arguments and returns ready-to-send messages.
*/
@Component
public class IncidentPrompts {
private final IncidentData data;
public IncidentPrompts(IncidentData data) {
this.data = data;
}
@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))));
}
}
@@ -0,0 +1,55 @@
package com.ankurm.mcpadvanced;
import java.util.List;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.stereotype.Component;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
/**
* Resources are data the host application (or the user) chooses to attach to a conversation.
* Unlike a tool, the model never decides to call one.
*/
@Component
public class IncidentResources {
private final IncidentData data;
public IncidentResources(IncidentData data) {
this.data = data;
}
/** A fixed resource: one URI, always listed by resources/list. */
@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)));
}
@McpResource(uri = "runbook://{service}", name = "runbook",
description = "Operations runbook for a service", mimeType = "text/markdown")
public ReadResourceResult runbook(String service) {
String text = data.runbook(service);
if (text == null) {
throw new IllegalArgumentException("No runbook for: " + service);
}
return new ReadResourceResult(List.of(
new TextResourceContents("runbook://" + service, "text/markdown", text)));
}
}
@@ -0,0 +1,71 @@
package com.ankurm.mcpadvanced;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
import org.springframework.ai.mcp.annotation.context.StructuredElicitResult;
import org.springframework.stereotype.Component;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
/** The two tools that call BACK into the client mid-request. */
@Component
public class IncidentTools {
private final IncidentData data;
public IncidentTools(IncidentData data) {
this.data = data;
}
/** 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();
}
/** Same call with NO gate, to show what a client without sampling does to it. */
@McpTool(name = "summarize_incident_ungated", description = "Like summarize_incident, without the capability check")
public String summarizeUngated(@McpToolParam(description = "e.g. INC-1042", required = true) String incidentId,
McpSyncRequestContext ctx) {
CreateMessageResult result = ctx.sample(spec -> spec
.message("Summarize this incident log:\n" + data.log(incidentId))
.maxTokens(120));
return ((TextContent) result.content()).text();
}
/** 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";
};
}
}
@@ -0,0 +1,17 @@
package com.ankurm.mcpadvanced;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* An "incident desk" MCP server that uses every server-side feature beyond plain tools:
* resources, a resource template, a prompt, and two tools that call back into the client
* (sampling and elicitation).
*/
@SpringBootApplication
public class McpAdvancedApplication {
public static void main(String[] args) {
SpringApplication.run(McpAdvancedApplication.class, args);
}
}
@@ -0,0 +1,12 @@
package com.ankurm.mcpadvanced;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
/**
* The form the user is asked to fill in. MCP elicitation only allows a FLAT object of
* primitives (string, number, boolean, enum) -- no nested objects, no arrays of objects.
*/
public record RestartApproval(
@JsonPropertyDescription("Tick to approve the restart") boolean confirm,
@JsonPropertyDescription("Change ticket number, e.g. CHG-2291") String ticket) {
}
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,294 @@
package com.ankurm.mcpadvanced;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
/** Drives the server with a plain MCP SDK client, so what you see is what is on the wire. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "spring.ai.mcp.server.request-timeout=3s")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class McpAdvancedTest {
@LocalServerPort
int port;
private McpSyncClient client(ClientCapabilities caps,
Function<CreateMessageRequest, CreateMessageResult> sampling,
Function<ElicitFormRequest, ElicitResult> elicitation) {
var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port)
.endpoint("/mcp").build();
var spec = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10)).capabilities(caps);
if (sampling != null) {
spec.sampling(sampling);
}
if (elicitation != null) {
spec.elicitation(elicitation);
}
McpSyncClient c = spec.build();
c.initialize();
return c;
}
private McpSyncClient plain() {
return client(ClientCapabilities.builder().build(), null, null);
}
private static String text(CallToolResult r) {
return ((TextContent) r.content().get(0)).text();
}
@Test
@Order(1)
void initializeAndCapabilities() {
ClientCapabilities caps = ClientCapabilities.builder().sampling().elicitation().build();
try (McpSyncClient c = client(caps, r -> null, r -> null)) {
var init = c.getCurrentInitializationResult();
var sc = init.capabilities();
Transcript t = new Transcript("01-initialize.txt", "initialize handshake (client declares sampling + elicitation)");
t.kv("server.name", init.serverInfo().name());
t.kv("negotiated protocolVersion", init.protocolVersion());
t.kv("server capability: tools", sc.tools() != null);
t.kv("server capability: resources", sc.resources() != null);
t.kv("server capability: prompts", sc.prompts() != null);
t.kv("server capability: logging", sc.logging() != null);
t.kv("client declared: sampling", caps.sampling() != null);
t.kv("client declared: elicitation", caps.elicitation() != null);
t.write();
assertThat(sc.resources()).isNotNull();
assertThat(sc.prompts()).isNotNull();
assertThat(init.protocolVersion()).isEqualTo("2025-11-25");
}
}
@Test
@Order(2)
void resources() {
try (McpSyncClient c = plain()) {
Transcript t = new Transcript("02-resources.txt", "resources/list, resources/templates/list, resources/read");
var fixed = c.listResources().resources();
fixed.forEach(r -> t.line("resource " + r.uri() + " name=" + r.name() + " mime=" + r.mimeType()));
var templates = c.listResourceTemplates().resourceTemplates();
// sorted: the server does not promise an order, and a stable transcript diffs cleanly
templates.stream().sorted(java.util.Comparator.comparing(McpSchema.ResourceTemplate::uriTemplate))
.forEach(r -> t.line("template " + r.uriTemplate() + " name=" + r.name()));
String index = ((McpSchema.TextResourceContents) c.readResource(
new McpSchema.ReadResourceRequest("incident://index")).contents().get(0)).text();
t.line("").line("read incident://index ->").line(index);
String log = ((McpSchema.TextResourceContents) c.readResource(
new McpSchema.ReadResourceRequest("incident://INC-1043/log")).contents().get(0)).text();
t.line("read incident://INC-1043/log ->").line(log);
String err;
try {
c.readResource(new McpSchema.ReadResourceRequest("incident://INC-9999/log"));
err = "no error";
} catch (Exception e) {
err = e.getClass().getSimpleName() + ": " + e.getMessage();
}
t.line("read incident://INC-9999/log -> " + err);
t.write();
assertThat(fixed).extracting(McpSchema.Resource::uri).containsExactly("incident://index");
assertThat(templates).extracting(McpSchema.ResourceTemplate::uriTemplate)
.containsExactlyInAnyOrder("incident://{id}/log", "runbook://{service}");
assertThat(index).isEqualTo("INC-1042\nINC-1043");
assertThat(err).isNotEqualTo("no error");
}
}
@Test
@Order(3)
void prompts() {
try (McpSyncClient c = plain()) {
Transcript t = new Transcript("03-prompts.txt", "prompts/list and prompts/get");
var prompts = c.listPrompts().prompts();
prompts.forEach(p -> {
t.line("prompt " + p.name() + " -- " + p.description());
p.arguments().forEach(a -> t.line(" arg " + a.name() + " required=" + a.required()));
});
var got = c.getPrompt(new GetPromptRequest("postmortem",
Map.of("incidentId", "INC-1043", "audience", "the on-call manager")));
t.line("").line("get postmortem(INC-1043, on-call manager) ->");
got.messages().forEach(m -> t.line("[" + m.role() + "] " + ((TextContent) m.content()).text()));
String err;
try {
c.getPrompt(new GetPromptRequest("postmortem", Map.of("incidentId", "INC-9999")));
err = "no error";
} catch (Exception e) {
err = e.getClass().getSimpleName() + ": " + e.getMessage();
}
t.line("").line("get postmortem(INC-9999) -> " + err);
t.write();
assertThat(prompts).extracting(McpSchema.Prompt::name).containsExactly("postmortem");
assertThat(got.messages()).hasSize(1);
assertThat(got.messages().get(0).role()).isEqualTo(Role.USER);
assertThat(err).isNotEqualTo("no error");
}
}
@Test
@Order(4)
void sampling() {
AtomicReference<CreateMessageRequest> seen = new AtomicReference<>();
Function<CreateMessageRequest, CreateMessageResult> llm = req -> {
seen.set(req);
return CreateMessageResult.builder(Role.ASSISTANT, "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.",
"scripted-model-1").build();
};
ClientCapabilities caps = ClientCapabilities.builder().sampling().build();
try (McpSyncClient c = client(caps, llm, null)) {
CallToolResult r = c.callTool(new CallToolRequest("summarize_incident", Map.of("incidentId", "INC-1042")));
CreateMessageRequest q = seen.get();
Transcript t = new Transcript("04-sampling.txt", "tools/call summarize_incident -> server sends sampling/createMessage to the client");
t.line("what the CLIENT received from the server:");
t.kv(" systemPrompt", q.systemPrompt());
t.kv(" messages", q.messages().size());
t.kv(" messages[0].role", q.messages().get(0).role());
t.kv(" messages[0] starts with", ((TextContent) q.messages().get(0).content()).text().lines().findFirst().orElse(""));
t.kv(" maxTokens", q.maxTokens());
t.kv(" temperature", q.temperature());
t.kv(" includeContext", q.includeContext());
t.line("").line("tool result returned to the caller:").line(text(r));
t.kv("isError", r.isError());
t.write();
assertThat(q.maxTokens()).isEqualTo(120);
assertThat(q.systemPrompt()).contains("SRE");
assertThat(text(r)).contains("212 clients");
assertThat(r.isError()).isFalse();
}
}
private String restart(ElicitResult answer, List<String> seenMessages, List<Object> seenSchemas) {
ClientCapabilities caps = ClientCapabilities.builder().elicitation().build();
try (McpSyncClient c = client(caps, null, req -> {
seenMessages.add(req.message());
seenSchemas.add(req.requestedSchema());
return answer;
})) {
return text(c.callTool(new CallToolRequest("restart_service", Map.of("service", "payments-api"))));
}
}
@Test
@Order(5)
void elicitation() {
List<String> msgs = new ArrayList<>();
List<Object> schemas = new ArrayList<>();
String accepted = restart(ElicitResult.builder(ElicitResult.Action.ACCEPT)
.content(Map.of("confirm", true, "ticket", "CHG-2291")).build(), msgs, schemas);
String unticked = restart(ElicitResult.builder(ElicitResult.Action.ACCEPT)
.content(Map.of("confirm", false, "ticket", "")).build(), msgs, schemas);
String declined = restart(ElicitResult.builder(ElicitResult.Action.DECLINE).build(), msgs, schemas);
String cancelled = restart(ElicitResult.builder(ElicitResult.Action.CANCEL).build(), msgs, schemas);
Transcript t = new Transcript("05-elicitation.txt", "tools/call restart_service -> server sends elicitation/create to the client");
t.kv("message shown to the human", msgs.get(0));
t.kv("requestedSchema sent by the server", schemas.get(0));
t.line("");
t.kv("ACCEPT confirm=true ticket=CHG-2291", accepted);
t.kv("ACCEPT confirm=false", unticked);
t.kv("DECLINE", declined);
t.kv("CANCEL ", cancelled);
t.write();
assertThat(accepted).isEqualTo("RESTARTED payments-api under CHG-2291");
assertThat(unticked).startsWith("NOT RESTARTED -- form submitted");
assertThat(declined).isEqualTo("NOT RESTARTED -- operator declined");
assertThat(cancelled).isEqualTo("NOT RESTARTED -- operator dismissed the dialog");
assertThat(String.valueOf(schemas.get(0))).contains("confirm").contains("ticket");
}
@Test
@Order(6)
void clientWithoutCapabilities() {
try (McpSyncClient c = plain()) {
CallToolResult gated = c.callTool(new CallToolRequest("summarize_incident", Map.of("incidentId", "INC-1043")));
CallToolResult restart = c.callTool(new CallToolRequest("restart_service", Map.of("service", "payments-api")));
String ungatedErr;
boolean ungatedIsError = false;
try {
CallToolResult u = c.callTool(new CallToolRequest("summarize_incident_ungated", Map.of("incidentId", "INC-1043")));
ungatedIsError = Boolean.TRUE.equals(u.isError());
ungatedErr = "result: " + text(u);
} catch (Exception e) {
ungatedErr = "exception " + e.getClass().getSimpleName() + ": " + e.getMessage();
}
Transcript t = new Transcript("06-no-capabilities.txt", "a client that declared neither sampling nor elicitation");
t.line("summarize_incident (gated with sampleEnabled()):").line(text(gated));
t.line("").line("restart_service (gated with elicitEnabled()):").line(text(restart));
t.line("").line("summarize_incident_ungated (calls sample() with no check):").line(ungatedErr);
t.kv("isError", ungatedIsError);
t.write();
assertThat(text(gated)).startsWith("SAMPLING_UNAVAILABLE");
assertThat(text(restart)).startsWith("ELICITATION_UNAVAILABLE");
}
}
@Test
@Order(7)
void slowClientHitsServerTimeout() {
Function<CreateMessageRequest, CreateMessageResult> slow = req -> {
try {
Thread.sleep(5_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return CreateMessageResult.builder(Role.ASSISTANT, "too late", "slow-model").build();
};
ClientCapabilities caps = ClientCapabilities.builder().sampling().build();
try (McpSyncClient c = client(caps, slow, null)) {
long start = System.nanoTime();
String outcome;
try {
CallToolResult r = c.callTool(new CallToolRequest("summarize_incident", Map.of("incidentId", "INC-1042")));
outcome = "result isError=" + r.isError() + " text=" + text(r);
} catch (Exception e) {
outcome = "exception " + e.getClass().getSimpleName() + ": " + e.getMessage();
}
long ms = (System.nanoTime() - start) / 1_000_000;
Transcript t = new Transcript("07-slow-client-timeout.txt",
"client model takes 5s, server spring.ai.mcp.server.request-timeout=3s");
t.kv("outcome", outcome);
t.kv("returned in about (seconds, rounded)", Math.round(ms / 1000.0));
t.line("(timing is indicative, not a result)");
t.write();
assertThat(outcome).doesNotContain("too late");
}
}
}
@@ -0,0 +1,75 @@
package com.ankurm.mcpadvanced;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
/**
* The same server with spring.ai.mcp.server.protocol=STATELESS: no session, so nothing to send a
* server-to-client request down. This is the direction the 2026-07-28 spec takes the whole protocol.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "spring.ai.mcp.server.protocol=STATELESS")
class McpStatelessTest {
@LocalServerPort
int port;
@Test
void samplingOnAStatelessServer() {
AtomicInteger asked = new AtomicInteger();
var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint("/mcp").build();
try (McpSyncClient c = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10))
.capabilities(ClientCapabilities.builder().sampling().elicitation().build())
.sampling(r -> {
asked.incrementAndGet();
return CreateMessageResult.builder(Role.ASSISTANT, "never reached", "m").build();
})
.elicitation(r -> {
asked.incrementAndGet();
return null;
})
.build()) {
c.initialize();
Transcript t = new Transcript("08-stateless-server.txt",
"spring.ai.mcp.server.protocol=STATELESS, client declares sampling + elicitation");
t.kv("tools listed", c.listTools().tools().stream().map(x -> x.name()).sorted().toList());
String outcome;
try {
CallToolResult r = c.callTool(new CallToolRequest("summarize_incident", Map.of("incidentId", "INC-1043")));
outcome = "isError=" + r.isError() + " text=" + ((TextContent) r.content().get(0)).text();
} catch (Exception e) {
outcome = "exception " + e.getClass().getSimpleName() + ": " + e.getMessage();
}
t.kv("summarize_incident", outcome);
String outcome2;
try {
CallToolResult r = c.callTool(new CallToolRequest("restart_service", Map.of("service", "payments-api")));
outcome2 = "isError=" + r.isError() + " text=" + ((TextContent) r.content().get(0)).text();
} catch (Exception e) {
outcome2 = "exception " + e.getClass().getSimpleName() + ": " + e.getMessage();
}
t.kv("restart_service", outcome2);
t.kv("times the client's handlers were called", asked.get());
t.write();
assertThat(asked.get()).isZero();
}
}
}
@@ -0,0 +1,41 @@
package com.ankurm.mcpadvanced;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Collects lines for one scenario and writes them to output/NN-name.txt (relative to the module
* directory, which is Maven's working directory). The tests assert on the same values, so every
* figure in the article is an assertion that fails the build when it stops being true.
*/
final class Transcript {
private final StringBuilder sb = new StringBuilder();
private final String file;
Transcript(String file, String title) {
this.file = file;
sb.append("# ").append(title).append('\n');
}
Transcript line(String text) {
sb.append(text).append('\n');
return this;
}
Transcript kv(String key, Object value) {
return line(key + ": " + value);
}
void write() {
try {
Path dir = Path.of("output");
Files.createDirectories(dir);
Files.writeString(dir.resolve(file), sb.toString(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}