From 0006167c24287323be328adaa2eb5b96a6bbc63a Mon Sep 17 00:00:00 2001 From: Ankur Date: Tue, 10 Nov 2026 18:47:09 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017ic4b5jAFLJrmUXmbo5EuV --- README.md | 1 + mcp-advanced/.gitignore | 1 + mcp-advanced/README.md | 21 ++ mcp-advanced/output/01-initialize.txt | 9 + mcp-advanced/output/02-resources.txt | 13 + mcp-advanced/output/03-prompts.txt | 13 + mcp-advanced/output/04-sampling.txt | 13 + mcp-advanced/output/05-elicitation.txt | 8 + mcp-advanced/output/06-no-capabilities.txt | 14 + .../output/07-slow-client-timeout.txt | 5 + mcp-advanced/output/08-stateless-server.txt | 5 + mcp-advanced/output/09-stateless-warning.txt | 4 + mcp-advanced/output/10-wire-sampling.txt | 56 ++++ mcp-advanced/output/11-wire-elicitation.txt | 61 ++++ mcp-advanced/pom.xml | 66 ++++ mcp-advanced/scripts/run-all.sh | 19 ++ mcp-advanced/scripts/wire-demo.sh | 69 ++++ .../com/ankurm/mcpadvanced/IncidentData.java | 40 +++ .../ankurm/mcpadvanced/IncidentPrompts.java | 42 +++ .../ankurm/mcpadvanced/IncidentResources.java | 55 ++++ .../com/ankurm/mcpadvanced/IncidentTools.java | 71 +++++ .../mcpadvanced/McpAdvancedApplication.java | 17 + .../ankurm/mcpadvanced/RestartApproval.java | 12 + .../src/main/resources/application.properties | 8 + .../ankurm/mcpadvanced/McpAdvancedTest.java | 294 ++++++++++++++++++ .../ankurm/mcpadvanced/McpStatelessTest.java | 75 +++++ .../com/ankurm/mcpadvanced/Transcript.java | 41 +++ 27 files changed, 1033 insertions(+) create mode 100644 mcp-advanced/.gitignore create mode 100644 mcp-advanced/README.md create mode 100644 mcp-advanced/output/01-initialize.txt create mode 100644 mcp-advanced/output/02-resources.txt create mode 100644 mcp-advanced/output/03-prompts.txt create mode 100644 mcp-advanced/output/04-sampling.txt create mode 100644 mcp-advanced/output/05-elicitation.txt create mode 100644 mcp-advanced/output/06-no-capabilities.txt create mode 100644 mcp-advanced/output/07-slow-client-timeout.txt create mode 100644 mcp-advanced/output/08-stateless-server.txt create mode 100644 mcp-advanced/output/09-stateless-warning.txt create mode 100644 mcp-advanced/output/10-wire-sampling.txt create mode 100644 mcp-advanced/output/11-wire-elicitation.txt create mode 100644 mcp-advanced/pom.xml create mode 100755 mcp-advanced/scripts/run-all.sh create mode 100755 mcp-advanced/scripts/wire-demo.sh create mode 100644 mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentData.java create mode 100644 mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentPrompts.java create mode 100644 mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentResources.java create mode 100644 mcp-advanced/src/main/java/com/ankurm/mcpadvanced/IncidentTools.java create mode 100644 mcp-advanced/src/main/java/com/ankurm/mcpadvanced/McpAdvancedApplication.java create mode 100644 mcp-advanced/src/main/java/com/ankurm/mcpadvanced/RestartApproval.java create mode 100644 mcp-advanced/src/main/resources/application.properties create mode 100644 mcp-advanced/src/test/java/com/ankurm/mcpadvanced/McpAdvancedTest.java create mode 100644 mcp-advanced/src/test/java/com/ankurm/mcpadvanced/McpStatelessTest.java create mode 100644 mcp-advanced/src/test/java/com/ankurm/mcpadvanced/Transcript.java 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 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 msgs = new ArrayList<>(); + List 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 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"); + } + } +} diff --git a/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/McpStatelessTest.java b/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/McpStatelessTest.java new file mode 100644 index 0000000..806695a --- /dev/null +++ b/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/McpStatelessTest.java @@ -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(); + } + } +} diff --git a/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/Transcript.java b/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/Transcript.java new file mode 100644 index 0000000..38d071a --- /dev/null +++ b/mcp-advanced/src/test/java/com/ankurm/mcpadvanced/Transcript.java @@ -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); + } + } +}