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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b5jAFLJrmUXmbo5EuV
This commit is contained in:
2026-11-10 18:47:09 +00:00
co-authored by Claude Sonnet 5.5
parent 68da642f9a
commit 0006167c24
27 changed files with 1033 additions and 0 deletions
@@ -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) {
}