diff --git a/README.md b/README.md
index 6b00e22..4f89d29 100644
--- a/README.md
+++ b/README.md
@@ -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/) |
diff --git a/mcp-advanced/.gitignore b/mcp-advanced/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/mcp-advanced/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/mcp-advanced/README.md b/mcp-advanced/README.md
new file mode 100644
index 0000000..dba7007
--- /dev/null
+++ b/mcp-advanced/README.md
@@ -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.
diff --git a/mcp-advanced/output/01-initialize.txt b/mcp-advanced/output/01-initialize.txt
new file mode 100644
index 0000000..7290294
--- /dev/null
+++ b/mcp-advanced/output/01-initialize.txt
@@ -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
diff --git a/mcp-advanced/output/02-resources.txt b/mcp-advanced/output/02-resources.txt
new file mode 100644
index 0000000..2b0c06e
--- /dev/null
+++ b/mcp-advanced/output/02-resources.txt
@@ -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
diff --git a/mcp-advanced/output/03-prompts.txt b/mcp-advanced/output/03-prompts.txt
new file mode 100644
index 0000000..51f53fa
--- /dev/null
+++ b/mcp-advanced/output/03-prompts.txt
@@ -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
diff --git a/mcp-advanced/output/04-sampling.txt b/mcp-advanced/output/04-sampling.txt
new file mode 100644
index 0000000..78fab76
--- /dev/null
+++ b/mcp-advanced/output/04-sampling.txt
@@ -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
diff --git a/mcp-advanced/output/05-elicitation.txt b/mcp-advanced/output/05-elicitation.txt
new file mode 100644
index 0000000..ae06256
--- /dev/null
+++ b/mcp-advanced/output/05-elicitation.txt
@@ -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
diff --git a/mcp-advanced/output/06-no-capabilities.txt b/mcp-advanced/output/06-no-capabilities.txt
new file mode 100644
index 0000000..09c5aa6
--- /dev/null
+++ b/mcp-advanced/output/06-no-capabilities.txt
@@ -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
diff --git a/mcp-advanced/output/07-slow-client-timeout.txt b/mcp-advanced/output/07-slow-client-timeout.txt
new file mode 100644
index 0000000..4447de3
--- /dev/null
+++ b/mcp-advanced/output/07-slow-client-timeout.txt
@@ -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)
diff --git a/mcp-advanced/output/08-stateless-server.txt b/mcp-advanced/output/08-stateless-server.txt
new file mode 100644
index 0000000..f10ebed
--- /dev/null
+++ b/mcp-advanced/output/08-stateless-server.txt
@@ -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
diff --git a/mcp-advanced/output/09-stateless-warning.txt b/mcp-advanced/output/09-stateless-warning.txt
new file mode 100644
index 0000000..3355810
--- /dev/null
+++ b/mcp-advanced/output/09-stateless-warning.txt
@@ -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
diff --git a/mcp-advanced/output/10-wire-sampling.txt b/mcp-advanced/output/10-wire-sampling.txt
new file mode 100644
index 0000000..af8b45a
--- /dev/null
+++ b/mcp-advanced/output/10-wire-sampling.txt
@@ -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
+ }
+}
diff --git a/mcp-advanced/output/11-wire-elicitation.txt b/mcp-advanced/output/11-wire-elicitation.txt
new file mode 100644
index 0000000..3592ac3
--- /dev/null
+++ b/mcp-advanced/output/11-wire-elicitation.txt
@@ -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
+ }
+}
diff --git a/mcp-advanced/pom.xml b/mcp-advanced/pom.xml
new file mode 100644
index 0000000..3c6ad34
--- /dev/null
+++ b/mcp-advanced/pom.xml
@@ -0,0 +1,66 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.2
+
+
+
+ com.ankurm
+ mcp-advanced
+ 1.0.0
+ mcp-advanced
+ The half of MCP beyond tools: resources, prompts, sampling and elicitation, on Spring AI 2.0.2 and MCP Java SDK 2.1.0
+
+
+ 25
+
+ 2.0.2
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-bom
+ ${spring-ai.version}
+ pom
+ import
+
+
+
+
+
+
+ org.springframework.ai
+ spring-ai-starter-mcp-server-webmvc
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ -Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8
+
+
+
+
+
diff --git a/mcp-advanced/scripts/run-all.sh b/mcp-advanced/scripts/run-all.sh
new file mode 100755
index 0000000..0798d93
--- /dev/null
+++ b/mcp-advanced/scripts/run-all.sh
@@ -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
diff --git a/mcp-advanced/scripts/wire-demo.sh b/mcp-advanced/scripts/wire-demo.sh
new file mode 100755
index 0000000..4a5774d
--- /dev/null
+++ b/mcp-advanced/scripts/wire-demo.sh
@@ -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"
diff --git a/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentData.java b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentData.java
new file mode 100644
index 0000000..12a46b6
--- /dev/null
+++ b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentData.java
@@ -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 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 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 incidentIds() {
+ return logs.keySet().stream().sorted().toList();
+ }
+}
diff --git a/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentPrompts.java b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentPrompts.java
new file mode 100644
index 0000000..52e9d68
--- /dev/null
+++ b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentPrompts.java
@@ -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))));
+ }
+}
diff --git a/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentResources.java b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentResources.java
new file mode 100644
index 0000000..41582fc
--- /dev/null
+++ b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentResources.java
@@ -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)));
+ }
+}
diff --git a/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentTools.java b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentTools.java
new file mode 100644
index 0000000..f391f11
--- /dev/null
+++ b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentTools.java
@@ -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 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";
+ };
+ }
+}
diff --git a/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/McpAdvancedApplication.java b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/McpAdvancedApplication.java
new file mode 100644
index 0000000..5e837c9
--- /dev/null
+++ b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/McpAdvancedApplication.java
@@ -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);
+ }
+}
diff --git a/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/RestartApproval.java b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/RestartApproval.java
new file mode 100644
index 0000000..f91cf84
--- /dev/null
+++ b/mcp-advanced/src/main/java/com/ankurm/mcpadvanced/RestartApproval.java
@@ -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) {
+}
diff --git a/mcp-advanced/src/main/resources/application.properties b/mcp-advanced/src/main/resources/application.properties
new file mode 100644
index 0000000..2466d9f
--- /dev/null
+++ b/mcp-advanced/src/main/resources/application.properties
@@ -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
diff --git a/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/McpAdvancedTest.java b/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/McpAdvancedTest.java
new file mode 100644
index 0000000..273c634
--- /dev/null
+++ b/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/McpAdvancedTest.java
@@ -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 sampling,
+ Function 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 seen = new AtomicReference<>();
+ Function 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 seenMessages, List