Add mcp-advanced module: resources, prompts, sampling and elicitation on Spring AI 2.0.2 and MCP Java SDK 2.1.0
An incident-desk MCP server with a fixed resource, two resource templates, a prompt, a tool that borrows the client's model (sampling) and a tool that asks the operator to approve a restart (elicitation). Driven by a plain SDK client in JUnit and by raw curl, so the JSON-RPC for both server-to-client requests is committed. Also captured: a client that declared neither capability (a gated tool degrades, an ungated one returns an isError result), a slow client hitting the server request timeout, and protocol=STATELESS silently dropping every tool that takes McpSyncRequestContext (one WARN line, an empty tools/list). Negotiated protocol version is 2025-11-25; the 2026-07-28 spec is not implemented by SDK 2.1.0. No docs/ folder. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b5jAFLJrmUXmbo5EuV
This commit is contained in:
@@ -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-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-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-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/) |
|
| [`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/) |
|
| [`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/) |
|
| [`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/) |
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# tools/call restart_service -> server sends elicitation/create to the client
|
||||||
|
message shown to the human: Restart payments-api? This drops in-flight requests.
|
||||||
|
requestedSchema sent by the server: {type=object, properties={confirm={type=boolean, description=Tick to approve the restart}, ticket={type=string, description=Change ticket number, e.g. CHG-2291}}, required=[confirm, ticket]}
|
||||||
|
|
||||||
|
ACCEPT confirm=true ticket=CHG-2291: RESTARTED payments-api under CHG-2291
|
||||||
|
ACCEPT confirm=false: NOT RESTARTED -- form submitted with the box unticked
|
||||||
|
DECLINE: NOT RESTARTED -- operator declined
|
||||||
|
CANCEL : NOT RESTARTED -- operator dismissed the dialog
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# a client that declared neither sampling nor elicitation
|
||||||
|
summarize_incident (gated with sampleEnabled()):
|
||||||
|
SAMPLING_UNAVAILABLE -- client has no model to lend. Raw log:
|
||||||
|
11:02:44 search-api WARN index segment merge running
|
||||||
|
11:02:51 search-api INFO p99 latency 840ms (SLO 500ms)
|
||||||
|
|
||||||
|
|
||||||
|
restart_service (gated with elicitEnabled()):
|
||||||
|
ELICITATION_UNAVAILABLE -- refusing to restart payments-api without an approval channel
|
||||||
|
|
||||||
|
summarize_incident_ungated (calls sample() with no check):
|
||||||
|
result: Sampling not supported by the client: Implementation[name=Java SDK MCP Client, title=null, version=0.15.0, description=null, icons=null, websiteUrl=null]
|
||||||
|
Sampling not supported by the client: Implementation[name=Java SDK MCP Client, title=null, version=0.15.0, description=null, icons=null, websiteUrl=null]
|
||||||
|
isError: true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# client model takes 5s, server spring.ai.mcp.server.request-timeout=3s
|
||||||
|
outcome: result isError=true text=java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 3000ms in 'source(MonoCreate)' (and no fallback has been configured)
|
||||||
|
Did not observe any item or terminal signal within 3000ms in 'source(MonoCreate)' (and no fallback has been configured)
|
||||||
|
returned in about (seconds, rounded): 3
|
||||||
|
(timing is indicative, not a result)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# spring.ai.mcp.server.protocol=STATELESS, client declares sampling + elicitation
|
||||||
|
tools listed: []
|
||||||
|
summarize_incident: exception McpError: Unknown tool: invalid_tool_name
|
||||||
|
restart_service: exception McpError: Unknown tool: invalid_tool_name
|
||||||
|
times the client's handlers were called: 0
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# log line from the STATELESS test run (spring.ai.mcp.server.protocol=STATELESS)
|
||||||
|
McpPredicates : Stateless servers doesn't support bidirectional parameters. Skipping method public java.lang.String com.ankurm.mcpadvanced.IncidentTools.restart(java.lang.String,org.springframework.ai.mcp.annotation.context.McpSyncRequestContext) with bidirectional parameters
|
||||||
|
McpPredicates : Stateless servers doesn't support bidirectional parameters. Skipping method public java.lang.String com.ankurm.mcpadvanced.IncidentTools.summarize(java.lang.String,org.springframework.ai.mcp.annotation.context.McpSyncRequestContext) with bidirectional parameters
|
||||||
|
McpPredicates : Stateless servers doesn't support bidirectional parameters. Skipping method public java.lang.String com.ankurm.mcpadvanced.IncidentTools.summarizeUngated(java.lang.String,org.springframework.ai.mcp.annotation.context.McpSyncRequestContext) with bidirectional parameters
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# sampling/createMessage on the wire
|
||||||
|
|
||||||
|
## 1. client -> server (POST, answers arrive later on the same session)
|
||||||
|
tools/call summarize_incident {"incidentId":"INC-1043"}
|
||||||
|
|
||||||
|
## 2. server -> client, on the SSE stream of that same POST
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "sampling/createMessage",
|
||||||
|
"id": "7899c995-7e19-45a9-8b27-a043acabe802-0",
|
||||||
|
"params": {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": {
|
||||||
|
"type": "text",
|
||||||
|
"text": "Summarize this incident log:\n11:02:44 search-api WARN index segment merge running\n11:02:51 search-api INFO p99 latency 840ms (SLO 500ms)\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"systemPrompt": "You are an SRE. Answer in two sentences, no preamble.",
|
||||||
|
"includeContext": "none",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"maxTokens": 120
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 3. client -> server, a NEW POST carrying the answer (same id as the server's request)
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "7899c995-7e19-45a9-8b27-a043acabe802-0",
|
||||||
|
"result": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": {
|
||||||
|
"type": "text",
|
||||||
|
"text": "A segment merge pushed p99 to 840ms against a 500ms SLO."
|
||||||
|
},
|
||||||
|
"model": "curl-model",
|
||||||
|
"stopReason": "endTurn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 4. server -> client, the original tools/call finally completes
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 7,
|
||||||
|
"result": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "A segment merge pushed p99 to 840ms against a 500ms SLO."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# elicitation/create on the wire
|
||||||
|
|
||||||
|
## 1. client -> server (POST, answers arrive later on the same session)
|
||||||
|
tools/call restart_service {"service":"payments-api"}
|
||||||
|
|
||||||
|
## 2. server -> client, on the SSE stream of that same POST
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "elicitation/create",
|
||||||
|
"id": "7899c995-7e19-45a9-8b27-a043acabe802-1",
|
||||||
|
"params": {
|
||||||
|
"message": "Restart payments-api? This drops in-flight requests.",
|
||||||
|
"requestedSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"confirm": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Tick to approve the restart"
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Change ticket number, e.g. CHG-2291"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"confirm",
|
||||||
|
"ticket"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"_meta": {},
|
||||||
|
"mode": "form"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 3. client -> server, a NEW POST carrying the answer (same id as the server's request)
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "7899c995-7e19-45a9-8b27-a043acabe802-1",
|
||||||
|
"result": {
|
||||||
|
"action": "accept",
|
||||||
|
"content": {
|
||||||
|
"confirm": true,
|
||||||
|
"ticket": "CHG-2291"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 4. server -> client, the original tools/call finally completes
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 7,
|
||||||
|
"result": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "RESTARTED payments-api under CHG-2291"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.2</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>mcp-advanced</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>mcp-advanced</name>
|
||||||
|
<description>The half of MCP beyond tools: resources, prompts, sampling and elicitation, on Spring AI 2.0.2 and MCP Java SDK 2.1.0</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<!-- Spring AI is not managed by the Spring Boot BOM: this pair is yours to keep compatible. -->
|
||||||
|
<spring-ai.version>2.0.2</spring-ai.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-bom</artifactId>
|
||||||
|
<version>${spring-ai.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<argLine>-Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+19
@@ -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
|
||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shows the sampling and elicitation messages as raw JSON-RPC over Streamable HTTP, using curl as the
|
||||||
|
# "client". Needs: target/mcp-advanced-1.0.0.jar (mvn package -DskipTests), JDK 25 on PATH, curl, python3.
|
||||||
|
# Output goes to output/10-wire-sampling.txt and output/11-wire-elicitation.txt.
|
||||||
|
set -u
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
URL=http://localhost:8089/mcp
|
||||||
|
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
|
||||||
|
OUT=output
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
|
||||||
|
setsid nohup java -Dserver.port=8089 -jar target/mcp-advanced-1.0.0.jar > "$TMP/app.log" 2>&1 < /dev/null &
|
||||||
|
APP_PID=$!
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
curl -s --noproxy '*' -o /dev/null -X POST "$URL" "${H[@]}" -d '{}' && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- initialize, declaring BOTH sampling and elicitation ---------------------------------------
|
||||||
|
curl -s --noproxy '*' -D "$TMP/init.h" -o "$TMP/init.b" -X POST "$URL" "${H[@]}" -d '{
|
||||||
|
"jsonrpc":"2.0","id":1,"method":"initialize",
|
||||||
|
"params":{"protocolVersion":"2025-11-25",
|
||||||
|
"capabilities":{"sampling":{},"elicitation":{}},
|
||||||
|
"clientInfo":{"name":"curl-client","version":"0"}}}'
|
||||||
|
SID=$(grep -i '^mcp-session-id:' "$TMP/init.h" | awk '{print $2}' | tr -d '\r')
|
||||||
|
curl -s --noproxy '*' -o /dev/null -X POST "$URL" "${H[@]}" -H "Mcp-Session-Id: $SID" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
||||||
|
|
||||||
|
# Run one tool call, read the server's request out of the SSE stream, answer it, then show the final result.
|
||||||
|
roundtrip () { # $1 = tool name $2 = arguments json $3 = answer result json $4 = output file $5 = title
|
||||||
|
local call_id=7
|
||||||
|
curl -s --noproxy '*' -N -X POST "$URL" "${H[@]}" -H "Mcp-Session-Id: $SID" \
|
||||||
|
-d "{\"jsonrpc\":\"2.0\",\"id\":$call_id,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}" \
|
||||||
|
> "$TMP/stream.txt" &
|
||||||
|
local cpid=$!
|
||||||
|
for i in $(seq 1 40); do grep -q '"method":"\(sampling/createMessage\|elicitation/create\)"' "$TMP/stream.txt" 2>/dev/null && break; sleep 0.25; done
|
||||||
|
local req_line req_id
|
||||||
|
req_line=$(grep '^data:' "$TMP/stream.txt" | grep -E 'sampling/createMessage|elicitation/create' | head -1 | sed 's/^data: *//')
|
||||||
|
req_id=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["id"])' "$req_line")
|
||||||
|
curl -s --noproxy '*' -o /dev/null -X POST "$URL" "${H[@]}" -H "Mcp-Session-Id: $SID" \
|
||||||
|
-d "{\"jsonrpc\":\"2.0\",\"id\":\"$req_id\",\"result\":$3}"
|
||||||
|
wait $cpid
|
||||||
|
{
|
||||||
|
echo "# $5"
|
||||||
|
echo
|
||||||
|
echo "## 1. client -> server (POST, answers arrive later on the same session)"
|
||||||
|
echo "tools/call $1 $2"
|
||||||
|
echo
|
||||||
|
echo "## 2. server -> client, on the SSE stream of that same POST"
|
||||||
|
python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1]),indent=2))' "$req_line"
|
||||||
|
echo
|
||||||
|
echo "## 3. client -> server, a NEW POST carrying the answer (same id as the server's request)"
|
||||||
|
python3 -c 'import json,sys; print(json.dumps({"jsonrpc":"2.0","id":sys.argv[1],"result":json.loads(sys.argv[2])},indent=2))' "$req_id" "$3"
|
||||||
|
echo
|
||||||
|
echo "## 4. server -> client, the original tools/call finally completes"
|
||||||
|
grep '^data:' "$TMP/stream.txt" | tail -1 | sed 's/^data: *//' | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin),indent=2))'
|
||||||
|
} > "$OUT/$4"
|
||||||
|
}
|
||||||
|
|
||||||
|
roundtrip summarize_incident '{"incidentId":"INC-1043"}' \
|
||||||
|
'{"role":"assistant","content":{"type":"text","text":"A segment merge pushed p99 to 840ms against a 500ms SLO."},"model":"curl-model","stopReason":"endTurn"}' \
|
||||||
|
10-wire-sampling.txt "sampling/createMessage on the wire"
|
||||||
|
|
||||||
|
roundtrip restart_service '{"service":"payments-api"}' \
|
||||||
|
'{"action":"accept","content":{"confirm":true,"ticket":"CHG-2291"}}' \
|
||||||
|
11-wire-elicitation.txt "elicitation/create on the wire"
|
||||||
|
|
||||||
|
kill "$APP_PID" 2>/dev/null
|
||||||
|
rm -rf "$TMP"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Fixed in-memory data so every run of the demo reads the same bytes. */
|
||||||
|
@Component
|
||||||
|
public class IncidentData {
|
||||||
|
|
||||||
|
private final Map<String, String> logs = Map.of(
|
||||||
|
"INC-1042", """
|
||||||
|
09:14:02 payments-api WARN connection pool at 48/50
|
||||||
|
09:14:09 payments-api ERROR timeout acquiring connection after 30000ms
|
||||||
|
09:14:09 payments-api ERROR timeout acquiring connection after 30000ms
|
||||||
|
09:14:31 payments-api ERROR HTTP 503 returned to 212 clients
|
||||||
|
09:15:02 payments-db WARN slow query 14.8s: SELECT * FROM ledger WHERE account_id = ?
|
||||||
|
""",
|
||||||
|
"INC-1043", """
|
||||||
|
11:02:44 search-api WARN index segment merge running
|
||||||
|
11:02:51 search-api INFO p99 latency 840ms (SLO 500ms)
|
||||||
|
""");
|
||||||
|
|
||||||
|
private final Map<String, String> runbooks = Map.of(
|
||||||
|
"payments", "1. Check pool metrics. 2. Look for slow queries on payments-db. 3. Restart payments-api only after approval.",
|
||||||
|
"search", "1. Check segment merges. 2. Scale read replicas. 3. Restart is safe at any time.");
|
||||||
|
|
||||||
|
public String log(String incidentId) {
|
||||||
|
return logs.get(incidentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String runbook(String service) {
|
||||||
|
return runbooks.get(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> incidentIds() {
|
||||||
|
return logs.keySet().stream().sorted().toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.ai.mcp.annotation.McpArg;
|
||||||
|
import org.springframework.ai.mcp.annotation.McpPrompt;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.Role;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.TextContent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prompt is a template the USER picks (think slash command). The server fills in the
|
||||||
|
* arguments and returns ready-to-send messages.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class IncidentPrompts {
|
||||||
|
|
||||||
|
private final IncidentData data;
|
||||||
|
|
||||||
|
public IncidentPrompts(IncidentData data) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@McpPrompt(name = "postmortem", description = "Draft a blameless postmortem for an incident")
|
||||||
|
public GetPromptResult postmortem(
|
||||||
|
@McpArg(name = "incidentId", description = "e.g. INC-1042", required = true) String incidentId,
|
||||||
|
@McpArg(name = "audience", description = "who will read it", required = false) String audience) {
|
||||||
|
|
||||||
|
String log = data.log(incidentId);
|
||||||
|
if (log == null) {
|
||||||
|
throw new IllegalArgumentException("No such incident: " + incidentId);
|
||||||
|
}
|
||||||
|
String who = (audience == null || audience.isBlank()) ? "the engineering team" : audience;
|
||||||
|
String text = "Write a blameless postmortem for " + who + ". Sections: impact, timeline, "
|
||||||
|
+ "root cause, follow-ups. Use only the log below.\n\n" + log;
|
||||||
|
return new GetPromptResult("Postmortem for " + incidentId,
|
||||||
|
List.of(new PromptMessage(Role.USER, new TextContent(text))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.ai.mcp.annotation.McpResource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resources are data the host application (or the user) chooses to attach to a conversation.
|
||||||
|
* Unlike a tool, the model never decides to call one.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class IncidentResources {
|
||||||
|
|
||||||
|
private final IncidentData data;
|
||||||
|
|
||||||
|
public IncidentResources(IncidentData data) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fixed resource: one URI, always listed by resources/list. */
|
||||||
|
@McpResource(uri = "incident://index", name = "incident-index",
|
||||||
|
description = "Ids of all open incidents", mimeType = "text/plain")
|
||||||
|
public ReadResourceResult index() {
|
||||||
|
return new ReadResourceResult(List.of(
|
||||||
|
new TextResourceContents("incident://index", "text/plain",
|
||||||
|
String.join("\n", data.incidentIds()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resource template: {id} is filled from the URI the client asks for. */
|
||||||
|
@McpResource(uri = "incident://{id}/log", name = "incident-log",
|
||||||
|
description = "Raw log lines for one incident", mimeType = "text/plain")
|
||||||
|
public ReadResourceResult log(String id) {
|
||||||
|
String log = data.log(id);
|
||||||
|
if (log == null) {
|
||||||
|
throw new IllegalArgumentException("No such incident: " + id);
|
||||||
|
}
|
||||||
|
return new ReadResourceResult(List.of(
|
||||||
|
new TextResourceContents("incident://" + id + "/log", "text/plain", log)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@McpResource(uri = "runbook://{service}", name = "runbook",
|
||||||
|
description = "Operations runbook for a service", mimeType = "text/markdown")
|
||||||
|
public ReadResourceResult runbook(String service) {
|
||||||
|
String text = data.runbook(service);
|
||||||
|
if (text == null) {
|
||||||
|
throw new IllegalArgumentException("No runbook for: " + service);
|
||||||
|
}
|
||||||
|
return new ReadResourceResult(List.of(
|
||||||
|
new TextResourceContents("runbook://" + service, "text/markdown", text)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import org.springframework.ai.mcp.annotation.McpTool;
|
||||||
|
import org.springframework.ai.mcp.annotation.McpToolParam;
|
||||||
|
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
|
||||||
|
import org.springframework.ai.mcp.annotation.context.StructuredElicitResult;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.TextContent;
|
||||||
|
|
||||||
|
/** The two tools that call BACK into the client mid-request. */
|
||||||
|
@Component
|
||||||
|
public class IncidentTools {
|
||||||
|
|
||||||
|
private final IncidentData data;
|
||||||
|
|
||||||
|
public IncidentTools(IncidentData data) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sampling: the server borrows the client's model instead of owning an API key. */
|
||||||
|
@McpTool(name = "summarize_incident", description = "Summarize an incident log in two sentences")
|
||||||
|
public String summarize(@McpToolParam(description = "e.g. INC-1042", required = true) String incidentId,
|
||||||
|
McpSyncRequestContext ctx) {
|
||||||
|
String log = data.log(incidentId);
|
||||||
|
if (log == null) {
|
||||||
|
return "No such incident: " + incidentId;
|
||||||
|
}
|
||||||
|
// Capability gate: the client only gets sampling if it declared it during initialize.
|
||||||
|
if (!ctx.sampleEnabled()) {
|
||||||
|
return "SAMPLING_UNAVAILABLE -- client has no model to lend. Raw log:\n" + log;
|
||||||
|
}
|
||||||
|
CreateMessageResult result = ctx.sample(spec -> spec
|
||||||
|
.systemPrompt("You are an SRE. Answer in two sentences, no preamble.")
|
||||||
|
.message("Summarize this incident log:\n" + log)
|
||||||
|
.maxTokens(120)
|
||||||
|
.temperature(0.2));
|
||||||
|
return ((TextContent) result.content()).text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same call with NO gate, to show what a client without sampling does to it. */
|
||||||
|
@McpTool(name = "summarize_incident_ungated", description = "Like summarize_incident, without the capability check")
|
||||||
|
public String summarizeUngated(@McpToolParam(description = "e.g. INC-1042", required = true) String incidentId,
|
||||||
|
McpSyncRequestContext ctx) {
|
||||||
|
CreateMessageResult result = ctx.sample(spec -> spec
|
||||||
|
.message("Summarize this incident log:\n" + data.log(incidentId))
|
||||||
|
.maxTokens(120));
|
||||||
|
return ((TextContent) result.content()).text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elicitation: pause the tool and ask the HUMAN a structured question. */
|
||||||
|
@McpTool(name = "restart_service", description = "Restart a service after the operator approves")
|
||||||
|
public String restart(@McpToolParam(description = "service name, e.g. payments-api", required = true) String service,
|
||||||
|
McpSyncRequestContext ctx) {
|
||||||
|
if (!ctx.elicitEnabled()) {
|
||||||
|
return "ELICITATION_UNAVAILABLE -- refusing to restart " + service + " without an approval channel";
|
||||||
|
}
|
||||||
|
StructuredElicitResult<RestartApproval> answer = ctx.elicit(
|
||||||
|
spec -> spec.message("Restart " + service + "? This drops in-flight requests."),
|
||||||
|
RestartApproval.class);
|
||||||
|
|
||||||
|
return switch (answer.action()) {
|
||||||
|
case ACCEPT -> answer.structuredContent().confirm()
|
||||||
|
? "RESTARTED " + service + " under " + answer.structuredContent().ticket()
|
||||||
|
: "NOT RESTARTED -- form submitted with the box unticked";
|
||||||
|
case DECLINE -> "NOT RESTARTED -- operator declined";
|
||||||
|
case CANCEL -> "NOT RESTARTED -- operator dismissed the dialog";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An "incident desk" MCP server that uses every server-side feature beyond plain tools:
|
||||||
|
* resources, a resource template, a prompt, and two tools that call back into the client
|
||||||
|
* (sampling and elicitation).
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class McpAdvancedApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(McpAdvancedApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form the user is asked to fill in. MCP elicitation only allows a FLAT object of
|
||||||
|
* primitives (string, number, boolean, enum) -- no nested objects, no arrays of objects.
|
||||||
|
*/
|
||||||
|
public record RestartApproval(
|
||||||
|
@JsonPropertyDescription("Tick to approve the restart") boolean confirm,
|
||||||
|
@JsonPropertyDescription("Change ticket number, e.g. CHG-2291") String ticket) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
spring.application.name=mcp-advanced
|
||||||
|
spring.ai.mcp.server.name=incident-desk
|
||||||
|
spring.ai.mcp.server.version=1.0.0
|
||||||
|
spring.ai.mcp.server.type=SYNC
|
||||||
|
# STREAMABLE is Spring AI 2.0's default; written out because the demo's stateless test flips it.
|
||||||
|
spring.ai.mcp.server.protocol=STREAMABLE
|
||||||
|
spring.ai.mcp.server.request-timeout=20s
|
||||||
|
server.port=8089
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.MethodOrderer;
|
||||||
|
import org.junit.jupiter.api.Order;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestMethodOrder;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.client.McpClient;
|
||||||
|
import io.modelcontextprotocol.client.McpSyncClient;
|
||||||
|
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.Role;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.TextContent;
|
||||||
|
|
||||||
|
/** Drives the server with a plain MCP SDK client, so what you see is what is on the wire. */
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
|
properties = "spring.ai.mcp.server.request-timeout=3s")
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
class McpAdvancedTest {
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
int port;
|
||||||
|
|
||||||
|
private McpSyncClient client(ClientCapabilities caps,
|
||||||
|
Function<CreateMessageRequest, CreateMessageResult> sampling,
|
||||||
|
Function<ElicitFormRequest, ElicitResult> elicitation) {
|
||||||
|
var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port)
|
||||||
|
.endpoint("/mcp").build();
|
||||||
|
var spec = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10)).capabilities(caps);
|
||||||
|
if (sampling != null) {
|
||||||
|
spec.sampling(sampling);
|
||||||
|
}
|
||||||
|
if (elicitation != null) {
|
||||||
|
spec.elicitation(elicitation);
|
||||||
|
}
|
||||||
|
McpSyncClient c = spec.build();
|
||||||
|
c.initialize();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpSyncClient plain() {
|
||||||
|
return client(ClientCapabilities.builder().build(), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(CallToolResult r) {
|
||||||
|
return ((TextContent) r.content().get(0)).text();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
void initializeAndCapabilities() {
|
||||||
|
ClientCapabilities caps = ClientCapabilities.builder().sampling().elicitation().build();
|
||||||
|
try (McpSyncClient c = client(caps, r -> null, r -> null)) {
|
||||||
|
var init = c.getCurrentInitializationResult();
|
||||||
|
var sc = init.capabilities();
|
||||||
|
Transcript t = new Transcript("01-initialize.txt", "initialize handshake (client declares sampling + elicitation)");
|
||||||
|
t.kv("server.name", init.serverInfo().name());
|
||||||
|
t.kv("negotiated protocolVersion", init.protocolVersion());
|
||||||
|
t.kv("server capability: tools", sc.tools() != null);
|
||||||
|
t.kv("server capability: resources", sc.resources() != null);
|
||||||
|
t.kv("server capability: prompts", sc.prompts() != null);
|
||||||
|
t.kv("server capability: logging", sc.logging() != null);
|
||||||
|
t.kv("client declared: sampling", caps.sampling() != null);
|
||||||
|
t.kv("client declared: elicitation", caps.elicitation() != null);
|
||||||
|
t.write();
|
||||||
|
assertThat(sc.resources()).isNotNull();
|
||||||
|
assertThat(sc.prompts()).isNotNull();
|
||||||
|
assertThat(init.protocolVersion()).isEqualTo("2025-11-25");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
void resources() {
|
||||||
|
try (McpSyncClient c = plain()) {
|
||||||
|
Transcript t = new Transcript("02-resources.txt", "resources/list, resources/templates/list, resources/read");
|
||||||
|
var fixed = c.listResources().resources();
|
||||||
|
fixed.forEach(r -> t.line("resource " + r.uri() + " name=" + r.name() + " mime=" + r.mimeType()));
|
||||||
|
var templates = c.listResourceTemplates().resourceTemplates();
|
||||||
|
// sorted: the server does not promise an order, and a stable transcript diffs cleanly
|
||||||
|
templates.stream().sorted(java.util.Comparator.comparing(McpSchema.ResourceTemplate::uriTemplate))
|
||||||
|
.forEach(r -> t.line("template " + r.uriTemplate() + " name=" + r.name()));
|
||||||
|
|
||||||
|
String index = ((McpSchema.TextResourceContents) c.readResource(
|
||||||
|
new McpSchema.ReadResourceRequest("incident://index")).contents().get(0)).text();
|
||||||
|
t.line("").line("read incident://index ->").line(index);
|
||||||
|
String log = ((McpSchema.TextResourceContents) c.readResource(
|
||||||
|
new McpSchema.ReadResourceRequest("incident://INC-1043/log")).contents().get(0)).text();
|
||||||
|
t.line("read incident://INC-1043/log ->").line(log);
|
||||||
|
|
||||||
|
String err;
|
||||||
|
try {
|
||||||
|
c.readResource(new McpSchema.ReadResourceRequest("incident://INC-9999/log"));
|
||||||
|
err = "no error";
|
||||||
|
} catch (Exception e) {
|
||||||
|
err = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||||
|
}
|
||||||
|
t.line("read incident://INC-9999/log -> " + err);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(fixed).extracting(McpSchema.Resource::uri).containsExactly("incident://index");
|
||||||
|
assertThat(templates).extracting(McpSchema.ResourceTemplate::uriTemplate)
|
||||||
|
.containsExactlyInAnyOrder("incident://{id}/log", "runbook://{service}");
|
||||||
|
assertThat(index).isEqualTo("INC-1042\nINC-1043");
|
||||||
|
assertThat(err).isNotEqualTo("no error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(3)
|
||||||
|
void prompts() {
|
||||||
|
try (McpSyncClient c = plain()) {
|
||||||
|
Transcript t = new Transcript("03-prompts.txt", "prompts/list and prompts/get");
|
||||||
|
var prompts = c.listPrompts().prompts();
|
||||||
|
prompts.forEach(p -> {
|
||||||
|
t.line("prompt " + p.name() + " -- " + p.description());
|
||||||
|
p.arguments().forEach(a -> t.line(" arg " + a.name() + " required=" + a.required()));
|
||||||
|
});
|
||||||
|
var got = c.getPrompt(new GetPromptRequest("postmortem",
|
||||||
|
Map.of("incidentId", "INC-1043", "audience", "the on-call manager")));
|
||||||
|
t.line("").line("get postmortem(INC-1043, on-call manager) ->");
|
||||||
|
got.messages().forEach(m -> t.line("[" + m.role() + "] " + ((TextContent) m.content()).text()));
|
||||||
|
|
||||||
|
String err;
|
||||||
|
try {
|
||||||
|
c.getPrompt(new GetPromptRequest("postmortem", Map.of("incidentId", "INC-9999")));
|
||||||
|
err = "no error";
|
||||||
|
} catch (Exception e) {
|
||||||
|
err = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||||
|
}
|
||||||
|
t.line("").line("get postmortem(INC-9999) -> " + err);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(prompts).extracting(McpSchema.Prompt::name).containsExactly("postmortem");
|
||||||
|
assertThat(got.messages()).hasSize(1);
|
||||||
|
assertThat(got.messages().get(0).role()).isEqualTo(Role.USER);
|
||||||
|
assertThat(err).isNotEqualTo("no error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(4)
|
||||||
|
void sampling() {
|
||||||
|
AtomicReference<CreateMessageRequest> seen = new AtomicReference<>();
|
||||||
|
Function<CreateMessageRequest, CreateMessageResult> llm = req -> {
|
||||||
|
seen.set(req);
|
||||||
|
return CreateMessageResult.builder(Role.ASSISTANT, "Connection pool on payments-api was exhausted by a "
|
||||||
|
+ "14.8s ledger query, so 212 clients got HTTP 503. Fix the query or raise the pool.",
|
||||||
|
"scripted-model-1").build();
|
||||||
|
};
|
||||||
|
ClientCapabilities caps = ClientCapabilities.builder().sampling().build();
|
||||||
|
try (McpSyncClient c = client(caps, llm, null)) {
|
||||||
|
CallToolResult r = c.callTool(new CallToolRequest("summarize_incident", Map.of("incidentId", "INC-1042")));
|
||||||
|
CreateMessageRequest q = seen.get();
|
||||||
|
Transcript t = new Transcript("04-sampling.txt", "tools/call summarize_incident -> server sends sampling/createMessage to the client");
|
||||||
|
t.line("what the CLIENT received from the server:");
|
||||||
|
t.kv(" systemPrompt", q.systemPrompt());
|
||||||
|
t.kv(" messages", q.messages().size());
|
||||||
|
t.kv(" messages[0].role", q.messages().get(0).role());
|
||||||
|
t.kv(" messages[0] starts with", ((TextContent) q.messages().get(0).content()).text().lines().findFirst().orElse(""));
|
||||||
|
t.kv(" maxTokens", q.maxTokens());
|
||||||
|
t.kv(" temperature", q.temperature());
|
||||||
|
t.kv(" includeContext", q.includeContext());
|
||||||
|
t.line("").line("tool result returned to the caller:").line(text(r));
|
||||||
|
t.kv("isError", r.isError());
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(q.maxTokens()).isEqualTo(120);
|
||||||
|
assertThat(q.systemPrompt()).contains("SRE");
|
||||||
|
assertThat(text(r)).contains("212 clients");
|
||||||
|
assertThat(r.isError()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String restart(ElicitResult answer, List<String> seenMessages, List<Object> seenSchemas) {
|
||||||
|
ClientCapabilities caps = ClientCapabilities.builder().elicitation().build();
|
||||||
|
try (McpSyncClient c = client(caps, null, req -> {
|
||||||
|
seenMessages.add(req.message());
|
||||||
|
seenSchemas.add(req.requestedSchema());
|
||||||
|
return answer;
|
||||||
|
})) {
|
||||||
|
return text(c.callTool(new CallToolRequest("restart_service", Map.of("service", "payments-api"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(5)
|
||||||
|
void elicitation() {
|
||||||
|
List<String> msgs = new ArrayList<>();
|
||||||
|
List<Object> schemas = new ArrayList<>();
|
||||||
|
String accepted = restart(ElicitResult.builder(ElicitResult.Action.ACCEPT)
|
||||||
|
.content(Map.of("confirm", true, "ticket", "CHG-2291")).build(), msgs, schemas);
|
||||||
|
String unticked = restart(ElicitResult.builder(ElicitResult.Action.ACCEPT)
|
||||||
|
.content(Map.of("confirm", false, "ticket", "")).build(), msgs, schemas);
|
||||||
|
String declined = restart(ElicitResult.builder(ElicitResult.Action.DECLINE).build(), msgs, schemas);
|
||||||
|
String cancelled = restart(ElicitResult.builder(ElicitResult.Action.CANCEL).build(), msgs, schemas);
|
||||||
|
|
||||||
|
Transcript t = new Transcript("05-elicitation.txt", "tools/call restart_service -> server sends elicitation/create to the client");
|
||||||
|
t.kv("message shown to the human", msgs.get(0));
|
||||||
|
t.kv("requestedSchema sent by the server", schemas.get(0));
|
||||||
|
t.line("");
|
||||||
|
t.kv("ACCEPT confirm=true ticket=CHG-2291", accepted);
|
||||||
|
t.kv("ACCEPT confirm=false", unticked);
|
||||||
|
t.kv("DECLINE", declined);
|
||||||
|
t.kv("CANCEL ", cancelled);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(accepted).isEqualTo("RESTARTED payments-api under CHG-2291");
|
||||||
|
assertThat(unticked).startsWith("NOT RESTARTED -- form submitted");
|
||||||
|
assertThat(declined).isEqualTo("NOT RESTARTED -- operator declined");
|
||||||
|
assertThat(cancelled).isEqualTo("NOT RESTARTED -- operator dismissed the dialog");
|
||||||
|
assertThat(String.valueOf(schemas.get(0))).contains("confirm").contains("ticket");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(6)
|
||||||
|
void clientWithoutCapabilities() {
|
||||||
|
try (McpSyncClient c = plain()) {
|
||||||
|
CallToolResult gated = c.callTool(new CallToolRequest("summarize_incident", Map.of("incidentId", "INC-1043")));
|
||||||
|
CallToolResult restart = c.callTool(new CallToolRequest("restart_service", Map.of("service", "payments-api")));
|
||||||
|
String ungatedErr;
|
||||||
|
boolean ungatedIsError = false;
|
||||||
|
try {
|
||||||
|
CallToolResult u = c.callTool(new CallToolRequest("summarize_incident_ungated", Map.of("incidentId", "INC-1043")));
|
||||||
|
ungatedIsError = Boolean.TRUE.equals(u.isError());
|
||||||
|
ungatedErr = "result: " + text(u);
|
||||||
|
} catch (Exception e) {
|
||||||
|
ungatedErr = "exception " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||||
|
}
|
||||||
|
Transcript t = new Transcript("06-no-capabilities.txt", "a client that declared neither sampling nor elicitation");
|
||||||
|
t.line("summarize_incident (gated with sampleEnabled()):").line(text(gated));
|
||||||
|
t.line("").line("restart_service (gated with elicitEnabled()):").line(text(restart));
|
||||||
|
t.line("").line("summarize_incident_ungated (calls sample() with no check):").line(ungatedErr);
|
||||||
|
t.kv("isError", ungatedIsError);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(text(gated)).startsWith("SAMPLING_UNAVAILABLE");
|
||||||
|
assertThat(text(restart)).startsWith("ELICITATION_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(7)
|
||||||
|
void slowClientHitsServerTimeout() {
|
||||||
|
Function<CreateMessageRequest, CreateMessageResult> slow = req -> {
|
||||||
|
try {
|
||||||
|
Thread.sleep(5_000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
return CreateMessageResult.builder(Role.ASSISTANT, "too late", "slow-model").build();
|
||||||
|
};
|
||||||
|
ClientCapabilities caps = ClientCapabilities.builder().sampling().build();
|
||||||
|
try (McpSyncClient c = client(caps, slow, null)) {
|
||||||
|
long start = System.nanoTime();
|
||||||
|
String outcome;
|
||||||
|
try {
|
||||||
|
CallToolResult r = c.callTool(new CallToolRequest("summarize_incident", Map.of("incidentId", "INC-1042")));
|
||||||
|
outcome = "result isError=" + r.isError() + " text=" + text(r);
|
||||||
|
} catch (Exception e) {
|
||||||
|
outcome = "exception " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||||
|
}
|
||||||
|
long ms = (System.nanoTime() - start) / 1_000_000;
|
||||||
|
Transcript t = new Transcript("07-slow-client-timeout.txt",
|
||||||
|
"client model takes 5s, server spring.ai.mcp.server.request-timeout=3s");
|
||||||
|
t.kv("outcome", outcome);
|
||||||
|
t.kv("returned in about (seconds, rounded)", Math.round(ms / 1000.0));
|
||||||
|
t.line("(timing is indicative, not a result)");
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(outcome).doesNotContain("too late");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.client.McpClient;
|
||||||
|
import io.modelcontextprotocol.client.McpSyncClient;
|
||||||
|
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.Role;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema.TextContent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same server with spring.ai.mcp.server.protocol=STATELESS: no session, so nothing to send a
|
||||||
|
* server-to-client request down. This is the direction the 2026-07-28 spec takes the whole protocol.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
|
properties = "spring.ai.mcp.server.protocol=STATELESS")
|
||||||
|
class McpStatelessTest {
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
int port;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void samplingOnAStatelessServer() {
|
||||||
|
AtomicInteger asked = new AtomicInteger();
|
||||||
|
var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint("/mcp").build();
|
||||||
|
try (McpSyncClient c = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10))
|
||||||
|
.capabilities(ClientCapabilities.builder().sampling().elicitation().build())
|
||||||
|
.sampling(r -> {
|
||||||
|
asked.incrementAndGet();
|
||||||
|
return CreateMessageResult.builder(Role.ASSISTANT, "never reached", "m").build();
|
||||||
|
})
|
||||||
|
.elicitation(r -> {
|
||||||
|
asked.incrementAndGet();
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.build()) {
|
||||||
|
c.initialize();
|
||||||
|
Transcript t = new Transcript("08-stateless-server.txt",
|
||||||
|
"spring.ai.mcp.server.protocol=STATELESS, client declares sampling + elicitation");
|
||||||
|
t.kv("tools listed", c.listTools().tools().stream().map(x -> x.name()).sorted().toList());
|
||||||
|
String outcome;
|
||||||
|
try {
|
||||||
|
CallToolResult r = c.callTool(new CallToolRequest("summarize_incident", Map.of("incidentId", "INC-1043")));
|
||||||
|
outcome = "isError=" + r.isError() + " text=" + ((TextContent) r.content().get(0)).text();
|
||||||
|
} catch (Exception e) {
|
||||||
|
outcome = "exception " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||||
|
}
|
||||||
|
t.kv("summarize_incident", outcome);
|
||||||
|
String outcome2;
|
||||||
|
try {
|
||||||
|
CallToolResult r = c.callTool(new CallToolRequest("restart_service", Map.of("service", "payments-api")));
|
||||||
|
outcome2 = "isError=" + r.isError() + " text=" + ((TextContent) r.content().get(0)).text();
|
||||||
|
} catch (Exception e) {
|
||||||
|
outcome2 = "exception " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||||
|
}
|
||||||
|
t.kv("restart_service", outcome2);
|
||||||
|
t.kv("times the client's handlers were called", asked.get());
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(asked.get()).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.ankurm.mcpadvanced;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects lines for one scenario and writes them to output/NN-name.txt (relative to the module
|
||||||
|
* directory, which is Maven's working directory). The tests assert on the same values, so every
|
||||||
|
* figure in the article is an assertion that fails the build when it stops being true.
|
||||||
|
*/
|
||||||
|
final class Transcript {
|
||||||
|
|
||||||
|
private final StringBuilder sb = new StringBuilder();
|
||||||
|
private final String file;
|
||||||
|
|
||||||
|
Transcript(String file, String title) {
|
||||||
|
this.file = file;
|
||||||
|
sb.append("# ").append(title).append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Transcript line(String text) {
|
||||||
|
sb.append(text).append('\n');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Transcript kv(String key, Object value) {
|
||||||
|
return line(key + ": " + value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void write() {
|
||||||
|
try {
|
||||||
|
Path dir = Path.of("output");
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
Files.writeString(dir.resolve(file), sb.toString(), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user