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:
@@ -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) {
|
||||
}
|
||||
Reference in New Issue
Block a user