diff --git a/CHANGELOG.md b/CHANGELOG.md index 0778f6275..ad44ec1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ Versioning follows [Semantic Versioning](https://semver.org/); see `server/discover`, multi round-trip requests with sealed `requestState`, `subscriptions/listen`, caching hints, required client capabilities and SEP-2243 `Mcp-Param` header validation. The API may change in minor releases until 3.0. See [MCP Server (2026-07-28)](docs/server-modern.md). +- Add in-memory repositories for the 2026-07-28 server: `InMemorySyncToolRepository`, + `InMemorySyncPromptRepository`, `InMemorySyncResourceRepository` and their async counterparts + `InMemoryAsyncToolRepository`, `InMemoryAsyncPromptRepository` and `InMemoryAsyncResourceRepository`, with runtime add and remove, change broadcasts and paging. See [In-Memory Repositories](docs/server-modern.md#in-memory-repositories). - Add `McpTransportContext.putAll(Map)`, returning a context that combines an existing context with additional entries, which take precedence. - `McpTransportContext` no longer compares contexts by their contents in diff --git a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java index f89cc1a8c..a6eef16d7 100644 --- a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java +++ b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java @@ -1,12 +1,8 @@ package io.modelcontextprotocol.conformance.server.modern; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.function.BiFunction; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -14,7 +10,6 @@ import io.modelcontextprotocol.modern.McpSchema.AudioContent; import io.modelcontextprotocol.modern.McpSchema.BlobResourceContents; import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; -import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; import io.modelcontextprotocol.modern.McpSchema.CallToolResult; import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.CompleteRequest; @@ -23,7 +18,6 @@ import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; import io.modelcontextprotocol.modern.McpSchema.EmbeddedResource; import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; -import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; import io.modelcontextprotocol.modern.McpSchema.GetPromptResult; import io.modelcontextprotocol.modern.McpSchema.ImageContent; import io.modelcontextprotocol.modern.McpSchema.Implementation; @@ -31,9 +25,9 @@ import io.modelcontextprotocol.modern.McpSchema.Prompt; import io.modelcontextprotocol.modern.McpSchema.PromptArgument; import io.modelcontextprotocol.modern.McpSchema.PromptMessage; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; import io.modelcontextprotocol.modern.McpSchema.ReadResourceResult; import io.modelcontextprotocol.modern.McpSchema.Resource; -import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; import io.modelcontextprotocol.modern.McpSchema.Role; import io.modelcontextprotocol.modern.McpSchema.SamplingMessage; @@ -41,29 +35,25 @@ import io.modelcontextprotocol.modern.McpSchema.TextResourceContents; import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.InputResponses; -import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpServer; -import io.modelcontextprotocol.modern.server.McpSyncNotifier; import io.modelcontextprotocol.modern.server.McpSyncResponse; import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; import io.modelcontextprotocol.modern.server.feature.McpSyncPrompt; -import io.modelcontextprotocol.modern.server.feature.McpSyncPromptRepository; import io.modelcontextprotocol.modern.server.feature.McpSyncResource; -import io.modelcontextprotocol.modern.server.feature.McpSyncResourceRepository; import io.modelcontextprotocol.modern.server.feature.McpSyncResourceTemplate; import io.modelcontextprotocol.modern.server.feature.McpSyncTool; -import io.modelcontextprotocol.modern.server.feature.McpSyncToolRepository; import io.modelcontextprotocol.modern.server.feature.PromptsFeature; -import io.modelcontextprotocol.modern.server.feature.PromptsPage; -import io.modelcontextprotocol.modern.server.feature.ResourceTemplatesPage; import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; -import io.modelcontextprotocol.modern.server.feature.ResourcesPage; import io.modelcontextprotocol.modern.server.feature.ServerChange; import io.modelcontextprotocol.modern.server.feature.ToolsFeature; -import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncPromptRepository; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncToolRepository; import io.modelcontextprotocol.modern.server.transport.HttpServletMcpTransport; import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; +import io.modelcontextprotocol.util.DefaultMcpUriTemplateManager; +import io.modelcontextprotocol.util.McpUriTemplateManager; import org.apache.catalina.Context; import org.apache.catalina.LifecycleException; import org.apache.catalina.Wrapper; @@ -73,7 +63,8 @@ /** * Conformance server for the 2026-07-28 (stateless) revision, built on the - * {@code io.modelcontextprotocol.modern} server. + * {@code io.modelcontextprotocol.modern} server. Its tools, resources and prompts are + * served from the SDK's in-memory repositories. */ public class ModernConformanceServlet { @@ -127,7 +118,11 @@ public class ModernConformanceServlet { } """; - private static final Pattern TEMPLATE_URI = Pattern.compile("test://template/(.+)/data"); + private static final String TEMPLATE_URI = "test://template/{id}/data"; + + // Extracts {id} in the template's reader, which the resource repository only calls + // for URIs that matched this template. + private static final McpUriTemplateManager TEMPLATE_VARIABLES = new DefaultMcpUriTemplateManager(TEMPLATE_URI); private static final McpJsonMapper JSON = McpJsonDefaults.getMapper(); @@ -138,9 +133,9 @@ public static void main(String[] args) throws Exception { McpChangeFeed changes = new McpChangeFeed(); McpServer mcpServer = McpServer.builder() .serverInfo(Implementation.builder("mcp-modern-conformance-server", "1.0.0").build()) - .feature(ToolsFeature.ofSync(new ConformanceTools(changes))) - .feature(ResourcesFeature.ofSync(new ConformanceResources())) - .feature(PromptsFeature.ofSync(new ConformancePrompts())) + .feature(ToolsFeature.ofSync(tools(changes))) + .feature(ResourcesFeature.ofSync(resources())) + .feature(PromptsFeature.ofSync(prompts())) .subscriptions(changes) .build(); @@ -195,8 +190,8 @@ private static Tomcat createEmbeddedTomcat(HttpServletMcpTransport transport, in return tomcat; } - private static CallToolResult text(String text) { - return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); + private static McpSyncResponse text(String text) { + return McpSyncResponse.result(CallToolResult.builder().addContent(TextContent.builder(text).build()).build()); } private static CompleteResult completeFrom(List candidates, CompleteRequest request) { @@ -246,28 +241,6 @@ private static Map responseObject(Map inputRespo return InputResponses.get(inputResponses, key, Map.class, JSON).orElse(null); } - @FunctionalInterface - private interface Handler { - - McpSyncResponse handle(McpRequestContext ctx, R request); - - } - - @FunctionalInterface - private interface StreamingTool { - - CallToolResult run(McpRequestContext ctx, CallToolRequest request, McpSyncNotifier notifier); - - } - - private static Handler respond(BiFunction fn) { - return (ctx, request) -> McpSyncResponse.result(fn.apply(ctx, request)); - } - - private static Handler stream(StreamingTool tool) { - return (ctx, request) -> McpSyncResponse.streaming(notifier -> tool.run(ctx, request, notifier)); - } - private static final ClientCapabilities.Elicitation FORM = new ClientCapabilities.Elicitation( new ClientCapabilities.Elicitation.Form(), null); @@ -277,142 +250,179 @@ private static Handler stream(StreamingTool to private static final ClientCapabilities ROOTS = ClientCapabilities.builder().roots().build(); - private static final class ConformanceTools implements McpSyncToolRepository { - - private final Map tools = new LinkedHashMap<>(); + // Tools - ConformanceTools(McpChangeBroadcaster changes) { - add("test_simple_text", "Returns simple text content for testing", - respond((ctx, req) -> text("This is a simple text response for testing."))); - add("test_image_content", "Returns image content for testing", - respond((ctx, req) -> CallToolResult.builder() + /** + * The tools of the conformance scenarios. {@code test_trigger_*} broadcast list + * changes without changing the catalog, so that the listings other scenarios check do + * not depend on the order in which scenarios run. + */ + private static InMemorySyncToolRepository tools(McpChangeBroadcaster changes) { + return InMemorySyncToolRepository.builder() + .tool(McpSyncTool.of(Tool.builder("test_simple_text", EMPTY_JSON_SCHEMA) + .description("Returns simple text content for testing") + .build(), (ctx, req) -> text("This is a simple text response for testing."))) + .tool(McpSyncTool.of( + Tool.builder("test_image_content", EMPTY_JSON_SCHEMA) + .description("Returns image content for testing") + .build(), + (ctx, req) -> McpSyncResponse.result(CallToolResult.builder() .addContent(ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) - .build())); - add("test_audio_content", "Returns audio content for testing", - respond((ctx, req) -> CallToolResult.builder() + .build()))) + .tool(McpSyncTool.of( + Tool.builder("test_audio_content", EMPTY_JSON_SCHEMA) + .description("Returns audio content for testing") + .build(), + (ctx, req) -> McpSyncResponse.result(CallToolResult.builder() .addContent(AudioContent.builder(MINIMAL_WAV, "audio/wav").build()) - .build())); - add("test_embedded_resource", "Returns embedded resource content for testing", - respond((ctx, - req) -> CallToolResult.builder() - .addContent( - EmbeddedResource - .builder(TextResourceContents - .builder("test://embedded-resource", - "This is an embedded resource content.") - .mimeType("text/plain") - .build()) - .build()) - .build())); - add("test_multiple_content_types", "Returns multiple content types for testing", - respond((ctx, - req) -> CallToolResult.builder() - .addContent(TextContent.builder("Multiple content types test:").build()) - .addContent(ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) - .addContent( - EmbeddedResource - .builder(TextResourceContents - .builder("test://mixed-content-resource", - "{\"test\":\"data\",\"value\":123}") - .mimeType("application/json") - .build()) - .build()) - .build())); - add("test_error_handling", "Tool that returns an error for testing error handling", - respond((ctx, req) -> CallToolResult.builder() + .build()))) + .tool(McpSyncTool + .of(Tool.builder("test_embedded_resource", EMPTY_JSON_SCHEMA) + .description("Returns embedded resource content for testing") + .build(), + (ctx, req) -> McpSyncResponse.result(CallToolResult.builder() + .addContent(EmbeddedResource + .builder(TextResourceContents + .builder("test://embedded-resource", "This is an embedded resource content.") + .mimeType("text/plain") + .build()) + .build()) + .build()))) + .tool(McpSyncTool + .of(Tool.builder("test_multiple_content_types", EMPTY_JSON_SCHEMA) + .description("Returns multiple content types for testing") + .build(), + (ctx, req) -> McpSyncResponse.result(CallToolResult.builder() + .addContent(TextContent.builder("Multiple content types test:").build()) + .addContent(ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) + .addContent(EmbeddedResource + .builder(TextResourceContents + .builder("test://mixed-content-resource", "{\"test\":\"data\",\"value\":123}") + .mimeType("application/json") + .build()) + .build()) + .build()))) + .tool(McpSyncTool.of( + Tool.builder("test_error_handling", EMPTY_JSON_SCHEMA) + .description("Tool that returns an error for testing error handling") + .build(), + (ctx, req) -> McpSyncResponse.result(CallToolResult.builder() .addContent(TextContent.builder("This tool intentionally returns an error for testing").build()) .isError(true) - .build())); - add("test_tool_with_progress", "Tool that reports progress notifications", stream((ctx, req, notifier) -> { - notifier.progress(0, 100.0, null); - sleep(50); - notifier.progress(50, 100.0, null); - sleep(50); - notifier.progress(100, 100.0, null); - return text("Tool execution completed with progress"); - })); + .build()))) + .tool(McpSyncTool.of(Tool.builder("test_tool_with_progress", EMPTY_JSON_SCHEMA) + .description("Tool that reports progress notifications") + .build(), (ctx, req) -> McpSyncResponse.streaming(notifier -> { + notifier.progress(0, 100.0, null); + sleep(50); + notifier.progress(50, 100.0, null); + sleep(50); + notifier.progress(100, 100.0, null); + return CallToolResult.builder() + .addContent(TextContent.builder("Tool execution completed with progress").build()) + .build(); + }))) // SEP-1613 / SEP-2106 JSON Schema 2020-12 keyword preservation - add(Tool.builder("json_schema_2020_12_tool", JSON, JSON_SCHEMA_2020_12_INPUT) + .tool(McpSyncTool.of(Tool.builder("json_schema_2020_12_tool", JSON, JSON_SCHEMA_2020_12_INPUT) .description("Tool with JSON Schema 2020-12 features (SEP-1613, SEP-2106)") - .build(), respond((ctx, req) -> text("ok"))); + .build(), (ctx, req) -> text("ok"))) // SEP-2243 custom headers: the scenario exercises the first tool with an // x-mcp-header annotation, so this must stay the only annotated tool. - add(Tool.builder("test_x_mcp_header", JSON, X_MCP_HEADER_INPUT) + .tool(McpSyncTool.of(Tool.builder("test_x_mcp_header", JSON, X_MCP_HEADER_INPUT) .description("Tests SEP-2243 Mcp-Param-* server-side validation") - .build(), respond((ctx, req) -> { + .build(), (ctx, req) -> { Object region = req.arguments() != null ? req.arguments().get("region") : null; return text("region=" + (region != null ? region : "")); - })); + })) // SEP-2575 diagnostic tools - add("test_missing_capability", "Test tool requiring sampling", SAMPLING, - respond((ctx, req) -> text("Success"))); - add("test_streaming_elicitation", "Diagnostic tool validating response progress streams", - stream((ctx, req, notifier) -> { - notifier.progress(50, 100.0, null); - return text("Streaming complete"); - })); + .tool(McpSyncTool.of(Tool.builder("test_missing_capability", EMPTY_JSON_SCHEMA) + .description("Test tool requiring sampling") + .build(), SAMPLING, (ctx, req) -> text("Success"))) + .tool(McpSyncTool.of(Tool.builder("test_streaming_elicitation", EMPTY_JSON_SCHEMA) + .description("Diagnostic tool validating response progress streams") + .build(), (ctx, req) -> McpSyncResponse.streaming(notifier -> { + notifier.progress(50, 100.0, null); + return CallToolResult.builder() + .addContent(TextContent.builder("Streaming complete").build()) + .build(); + }))) // Logging is deprecated and unsupported, so no log is ever sent; the suite // checks exactly that. - add("test_logging_tool", "Diagnostic logging validator tool", - stream((ctx, req, notifier) -> text("Logging evaluated"))); - add("test_trigger_tool_change", "Emits a tools list-changed notification", respond((ctx, req) -> { - changes.broadcast(new ServerChange.ToolsListChanged()); - return text("Mutation triggered"); - })); - add("test_trigger_prompt_change", "Emits a prompts list-changed notification", respond((ctx, req) -> { - changes.broadcast(new ServerChange.PromptsListChanged()); - return text("Mutation triggered"); - })); + .tool(McpSyncTool.of(Tool.builder("test_logging_tool", + EMPTY_JSON_SCHEMA) + .description("Diagnostic logging validator tool") + .build(), + (ctx, req) -> McpSyncResponse.streaming(notifier -> CallToolResult.builder() + .addContent(TextContent.builder("Logging evaluated").build()) + .build()))) + .tool(McpSyncTool.of(Tool.builder("test_trigger_tool_change", EMPTY_JSON_SCHEMA) + .description("Emits a tools list-changed notification") + .build(), (ctx, req) -> { + changes.broadcast(new ServerChange.ToolsListChanged()); + return text("Mutation triggered"); + })) + .tool(McpSyncTool.of(Tool.builder("test_trigger_prompt_change", EMPTY_JSON_SCHEMA) + .description("Emits a prompts list-changed notification") + .build(), (ctx, req) -> { + changes.broadcast(new ServerChange.PromptsListChanged()); + return text("Mutation triggered"); + })) // SEP-2322 MRTR tools - add("test_input_required_result_elicitation", "MRTR: returns InputRequiredResult with elicitation request", - ELICITATION, respond((ctx, req) -> { - Map content = acceptedContent(req.inputResponses(), "user_name"); - if (content == null || !(content.get("name") instanceof String name)) { - return InputRequiredResult.builder() - .elicit("user_name", elicitString("What is your name?", "name")) - .build(); - } - return text("Hello, " + name + "!"); - })); - add("test_input_required_result_sampling", "MRTR: returns InputRequiredResult with sampling request", - SAMPLING, respond((ctx, req) -> { - Map response = responseObject(req.inputResponses(), "capital_question"); - if (response == null) { - return InputRequiredResult.builder() - .createMessage("capital_question", sample("What is the capital of France?", 100)) - .build(); - } - Object content = response.get("content"); - Object answer = content instanceof Map m ? m.get("text") : null; - return text("Sampling result: " + answer); - })); - add("test_input_required_result_list_roots", "MRTR: returns InputRequiredResult with roots/list request", - ROOTS, respond((ctx, req) -> { - Map response = responseObject(req.inputResponses(), "client_roots"); - if (response == null || !(response.get("roots") instanceof List roots)) { - return InputRequiredResult.builder().listRoots("client_roots").build(); - } - return text("Found " + roots.size() + " root(s): " + roots); - })); - add("test_input_required_result_request_state", "MRTR: returns InputRequiredResult with requestState", - ELICITATION, respond((ctx, req) -> { - Map content = acceptedContent(req.inputResponses(), "confirm"); - if ("request-state".equals(req.requestState()) && content != null - && Boolean.TRUE.equals(content.get("ok"))) { - return text("state-ok: requestState validated"); - } - return InputRequiredResult.builder() - .elicit("confirm", elicitConfirm()) - .requestState("request-state") - .build(); - })); - add("test_input_required_result_multiple_inputs", - "MRTR: returns InputRequiredResult with multiple input requests", - ClientCapabilities.builder().elicitation(FORM).sampling().roots().build(), respond((ctx, req) -> { + .tool(McpSyncTool.of(Tool.builder("test_input_required_result_elicitation", EMPTY_JSON_SCHEMA) + .description("MRTR: returns InputRequiredResult with elicitation request") + .build(), ELICITATION, (ctx, req) -> { + Map content = acceptedContent(req.inputResponses(), "user_name"); + if (content == null || !(content.get("name") instanceof String name)) { + return McpSyncResponse.result(InputRequiredResult.builder() + .elicit("user_name", elicitString("What is your name?", "name")) + .build()); + } + return text("Hello, " + name + "!"); + })) + .tool(McpSyncTool.of(Tool.builder("test_input_required_result_sampling", EMPTY_JSON_SCHEMA) + .description("MRTR: returns InputRequiredResult with sampling request") + .build(), SAMPLING, (ctx, req) -> { + Map response = responseObject(req.inputResponses(), "capital_question"); + if (response == null) { + return McpSyncResponse.result(InputRequiredResult.builder() + .createMessage("capital_question", sample("What is the capital of France?", 100)) + .build()); + } + Object content = response.get("content"); + Object answer = content instanceof Map m ? m.get("text") : null; + return text("Sampling result: " + answer); + })) + .tool(McpSyncTool.of(Tool.builder("test_input_required_result_list_roots", EMPTY_JSON_SCHEMA) + .description("MRTR: returns InputRequiredResult with roots/list request") + .build(), ROOTS, (ctx, req) -> { + Map response = responseObject(req.inputResponses(), "client_roots"); + if (response == null || !(response.get("roots") instanceof List roots)) { + return McpSyncResponse.result(InputRequiredResult.builder().listRoots("client_roots").build()); + } + return text("Found " + roots.size() + " root(s): " + roots); + })) + .tool(McpSyncTool.of(Tool.builder("test_input_required_result_request_state", EMPTY_JSON_SCHEMA) + .description("MRTR: returns InputRequiredResult with requestState") + .build(), ELICITATION, (ctx, req) -> { + Map content = acceptedContent(req.inputResponses(), "confirm"); + if ("request-state".equals(req.requestState()) && content != null + && Boolean.TRUE.equals(content.get("ok"))) { + return text("state-ok: requestState validated"); + } + return McpSyncResponse.result(InputRequiredResult.builder() + .elicit("confirm", elicitConfirm()) + .requestState("request-state") + .build()); + })) + .tool(McpSyncTool.of( + Tool.builder("test_input_required_result_multiple_inputs", EMPTY_JSON_SCHEMA) + .description("MRTR: returns InputRequiredResult with multiple input requests") + .build(), + ClientCapabilities.builder().elicitation(FORM).sampling().roots().build(), (ctx, req) -> { Map user = acceptedContent(req.inputResponses(), "user_name"); Map greeting = responseObject(req.inputResponses(), "greeting"); Map roots = responseObject(req.inputResponses(), "client_roots"); @@ -421,225 +431,164 @@ ELICITATION, respond((ctx, req) -> { return text("Name: " + user.get("name") + "; Roots: " + (roots.get("roots") instanceof List l ? l.size() : 0)); } - return InputRequiredResult.builder() + return McpSyncResponse.result(InputRequiredResult.builder() .elicit("user_name", elicitString("What is your name?", "name")) .createMessage("greeting", sample("Generate a greeting", 50)) .listRoots("client_roots") .requestState("multiple-inputs") - .build(); - })); - add("test_input_required_result_multi_round", "MRTR: multi-round InputRequiredResult workflow", ELICITATION, - respond((ctx, req) -> { - String state = req.requestState(); - if (state != null && state.startsWith("round-1")) { - Map step1 = acceptedContent(req.inputResponses(), "step1"); - if (step1 != null && step1.get("name") instanceof String name) { - return InputRequiredResult.builder() - .elicit("step2", elicitString("Step 2: What is your favorite color?", "color")) - .requestState("round-2:" + name) - .build(); - } + .build()); + })) + .tool(McpSyncTool.of(Tool.builder("test_input_required_result_multi_round", EMPTY_JSON_SCHEMA) + .description("MRTR: multi-round InputRequiredResult workflow") + .build(), ELICITATION, (ctx, req) -> { + String state = req.requestState(); + if (state != null && state.startsWith("round-1")) { + Map step1 = acceptedContent(req.inputResponses(), "step1"); + if (step1 != null && step1.get("name") instanceof String name) { + return McpSyncResponse.result(InputRequiredResult.builder() + .elicit("step2", elicitString("Step 2: What is your favorite color?", "color")) + .requestState("round-2:" + name) + .build()); } - if (state != null && state.startsWith("round-2:")) { - Map step2 = acceptedContent(req.inputResponses(), "step2"); - if (step2 != null && step2.get("color") instanceof String color) { - return text("Multi-round complete for " + state.substring("round-2:".length()) - + " who likes " + color); - } + } + if (state != null && state.startsWith("round-2:")) { + Map step2 = acceptedContent(req.inputResponses(), "step2"); + if (step2 != null && step2.get("color") instanceof String color) { + return text("Multi-round complete for " + state.substring("round-2:".length()) + + " who likes " + color); } - return InputRequiredResult.builder() - .elicit("step1", elicitString("Step 1: What is your name?", "name")) - .requestState("round-1") - .build(); - })); + } + return McpSyncResponse.result(InputRequiredResult.builder() + .elicit("step1", elicitString("Step 1: What is your name?", "name")) + .requestState("round-1") + .build()); + })) // Integrity protection comes from the server's RequestStateCodec: a tampered // requestState is rejected before this handler runs. - add("test_input_required_result_tampered_state", "MRTR: HMAC-signed requestState integrity test", - ELICITATION, respond((ctx, req) -> { - if ("tamper-test".equals(req.requestState()) - && responseObject(req.inputResponses(), "confirm") != null) { - return text("integrity-ok: state verified"); - } - return InputRequiredResult.builder() - .elicit("confirm", elicitConfirm()) - .requestState("tamper-test") - .build(); - })); - add("test_input_required_result_capabilities", "MRTR: respects client capabilities in inputRequests", - respond((ctx, req) -> { - if (req.inputResponses() != null && !req.inputResponses().isEmpty()) { - return text("capabilities-ok: received " + String.join(",", req.inputResponses().keySet())); - } - ClientCapabilities caps = ctx.clientCapabilities(); - if (!caps.supportsElicitationForm() && !caps.supportsSampling()) { - return text("No supported capabilities declared"); - } - InputRequiredResult.Builder builder = InputRequiredResult.builder() - .requestState("capabilities-test:" + UUID.randomUUID()); - if (caps.supportsElicitationForm()) { - builder.elicit("elicit_input", elicitString("Elicitation input", "value")); - } - if (caps.supportsSampling()) { - builder.createMessage("sample_input", sample("Sample request", 50)); - } - return builder.build(); - })); - } - - private void add(String name, String description, Handler handler) { - add(name, description, ClientCapabilities.NONE, handler); - } - - private void add(String name, String description, ClientCapabilities requiredCapabilities, - Handler handler) { - add(Tool.builder(name, EMPTY_JSON_SCHEMA).description(description).build(), requiredCapabilities, handler); - } - - private void add(Tool tool, Handler handler) { - add(tool, ClientCapabilities.NONE, handler); - } - - private void add(Tool tool, ClientCapabilities requiredCapabilities, - Handler handler) { - this.tools.put(tool.name(), McpSyncTool.of(tool, requiredCapabilities, handler::handle)); - } - - @Override - public ToolsPage list(McpRequestContext ctx, String cursor) { - return ToolsPage.of(this.tools.values().stream().map(McpSyncTool::definition).toList()); - } - - @Override - public McpSyncTool find(McpRequestContext ctx, String name) { - return this.tools.get(name); - } - - private static void sleep(long millis) { - try { - Thread.sleep(millis); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - + .tool(McpSyncTool.of(Tool.builder("test_input_required_result_tampered_state", EMPTY_JSON_SCHEMA) + .description("MRTR: HMAC-signed requestState integrity test") + .build(), ELICITATION, (ctx, req) -> { + if ("tamper-test".equals(req.requestState()) + && responseObject(req.inputResponses(), "confirm") != null) { + return text("integrity-ok: state verified"); + } + return McpSyncResponse.result(InputRequiredResult.builder() + .elicit("confirm", elicitConfirm()) + .requestState("tamper-test") + .build()); + })) + .tool(McpSyncTool.of(Tool.builder("test_input_required_result_capabilities", EMPTY_JSON_SCHEMA) + .description("MRTR: respects client capabilities in inputRequests") + .build(), (ctx, req) -> { + if (req.inputResponses() != null && !req.inputResponses().isEmpty()) { + return text("capabilities-ok: received " + String.join(",", req.inputResponses().keySet())); + } + ClientCapabilities caps = ctx.clientCapabilities(); + if (!caps.supportsElicitationForm() && !caps.supportsSampling()) { + return text("No supported capabilities declared"); + } + InputRequiredResult.Builder builder = InputRequiredResult.builder() + .requestState("capabilities-test:" + UUID.randomUUID()); + if (caps.supportsElicitationForm()) { + builder.elicit("elicit_input", elicitString("Elicitation input", "value")); + } + if (caps.supportsSampling()) { + builder.createMessage("sample_input", sample("Sample request", 50)); + } + return McpSyncResponse.result(builder.build()); + })) + .build(); } - private static final class ConformanceResources implements McpSyncResourceRepository { - - private static final List RESOURCES = List.of( - Resource.builder("test://static-text", "Static Text Resource") - .description("A static text resource for testing") - .mimeType("text/plain") - .build(), - Resource.builder("test://static-binary", "Static Binary Resource") - .description("A static binary resource for testing") - .mimeType("image/png") - .build(), - Resource.builder("test://watched-resource", "Watched Resource") - .description("A resource that can be subscribed to for updates") - .mimeType("text/plain") - .build()); - - @Override - public ResourcesPage list(McpRequestContext ctx, String cursor) { - return ResourcesPage.of(RESOURCES); - } - - private static final McpSyncResourceTemplate TEMPLATE = McpSyncResourceTemplate - .of(ResourceTemplate.builder("test://template/{id}/data", "Template Resource") - .description("A resource template for testing parameter substitution") - .mimeType("application/json") - .build()) - .withCompletionHandler((ctx, request) -> completeFrom(List.of("1", "2", "3"), request)); - - @Override - public ResourceTemplatesPage listTemplates(McpRequestContext ctx, String cursor) { - return ResourceTemplatesPage.of(List.of(TEMPLATE.definition())); - } - - @Override - public McpSyncResourceTemplate findTemplate(McpRequestContext ctx, String uriTemplate) { - return TEMPLATE.definition().uriTemplate().equals(uriTemplate) ? TEMPLATE : null; - } - - @Override - public McpSyncResource find(McpRequestContext ctx, String uri) { - ReadResourceResult result = read(uri); - return result != null ? McpSyncResource.of((c, request) -> McpSyncResponse.result(result)) : null; - } - - private static ReadResourceResult read(String uri) { - return switch (uri) { - case "test://static-text" -> textResource(uri, "This is the content of the static text resource."); - case "test://watched-resource" -> textResource(uri, "This is a watched resource content."); - case "test://static-binary" -> ReadResourceResult - .builder(List.of(BlobResourceContents.builder(uri, RED_PIXEL_PNG).mimeType("image/png").build())) - .build(); - default -> templateResource(uri); - }; - } - - @Override - public boolean supportsSubscribe() { - return true; - } - - @Override - public boolean supportsCompletion() { - return true; - } - - private static ReadResourceResult textResource(String uri, String text) { - return ReadResourceResult - .builder(List.of(TextResourceContents.builder(uri, text).mimeType("text/plain").build())) - .build(); + private static void sleep(long millis) { + try { + Thread.sleep(millis); } - - private static ReadResourceResult templateResource(String uri) { - Matcher matcher = TEMPLATE_URI.matcher(uri); - if (!matcher.matches()) { - return null; - } - String id = matcher.group(1); - String json = String.format("{\"id\":\"%s\",\"templateTest\":true,\"data\":\"Data for ID: %s\"}", id, id); - return ReadResourceResult - .builder(List.of(TextResourceContents.builder(uri, json).mimeType("application/json").build())) - .build(); + catch (InterruptedException e) { + Thread.currentThread().interrupt(); } + } + // Resources + + private static InMemorySyncResourceRepository resources() { + return InMemorySyncResourceRepository.builder() + .resource( + Resource.builder("test://static-text", "Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(), + McpSyncResource + .of((ctx, req) -> textResource(req.uri(), "This is the content of the static text resource."))) + .resource( + Resource.builder("test://static-binary", "Static Binary Resource") + .description("A static binary resource for testing") + .mimeType("image/png") + .build(), + McpSyncResource.of((ctx, req) -> McpSyncResponse.result(ReadResourceResult + .builder(List + .of(BlobResourceContents.builder(req.uri(), RED_PIXEL_PNG).mimeType("image/png").build())) + .build()))) + .resource( + Resource.builder("test://watched-resource", "Watched Resource") + .description("A resource that can be subscribed to for updates") + .mimeType("text/plain") + .build(), + McpSyncResource.of((ctx, req) -> textResource(req.uri(), "This is a watched resource content."))) + .template( + McpSyncResourceTemplate + .of(ResourceTemplate.builder(TEMPLATE_URI, "Template Resource") + .description("A resource template for testing parameter substitution") + .mimeType("application/json") + .build()) + .withCompletionHandler((ctx, request) -> completeFrom(List.of("1", "2", "3"), request)), + McpSyncResource.of((ctx, req) -> { + String id = TEMPLATE_VARIABLES.extractVariableValues(req.uri()).get("id"); + String json = String + .format("{\"id\":\"%s\",\"templateTest\":true,\"data\":\"Data for ID: %s\"}", id, id); + return McpSyncResponse.result(ReadResourceResult + .builder(List + .of(TextResourceContents.builder(req.uri(), json).mimeType("application/json").build())) + .build()); + })) + .supportsSubscribe(true) + .supportsCompletion(true) + .build(); } - private static final class ConformancePrompts implements McpSyncPromptRepository { + private static McpSyncResponse textResource(String uri, String text) { + return McpSyncResponse.result(ReadResourceResult + .builder(List.of(TextResourceContents.builder(uri, text).mimeType("text/plain").build())) + .build()); + } - private final Map prompts = new LinkedHashMap<>(); + // Prompts - ConformancePrompts() { - add(Prompt.builder("test_simple_prompt").description("A simple prompt for testing").build(), - respond((ctx, - req) -> messages(PromptMessage - .builder(Role.USER, TextContent.builder("This is a simple prompt for testing.").build()) - .build()))); - add(Prompt.builder("test_prompt_with_arguments") + private static InMemorySyncPromptRepository prompts() { + return InMemorySyncPromptRepository.builder() + .prompt(McpSyncPrompt.of( + Prompt.builder("test_simple_prompt").description("A simple prompt for testing").build(), + (ctx, req) -> messages(PromptMessage + .builder(Role.USER, TextContent.builder("This is a simple prompt for testing.").build()) + .build()))) + .prompt(McpSyncPrompt.of(Prompt.builder("test_prompt_with_arguments") .description("A prompt with arguments for testing") .arguments(List.of( PromptArgument.builder("arg1").description("First test argument").required(true).build(), PromptArgument.builder("arg2").description("Second test argument").required(true).build())) - .build(), respond((ctx, req) -> { + .build(), (ctx, req) -> { Map args = req.arguments() == null ? Map.of() : req.arguments(); String text = String.format("Prompt with arguments: arg1='%s', arg2='%s'", args.get("arg1"), args.get("arg2")); return messages(PromptMessage.builder(Role.USER, TextContent.builder(text).build()).build()); - })); - this.prompts.computeIfPresent("test_prompt_with_arguments", (name, prompt) -> prompt - .withCompletionHandler((ctx, request) -> completeFrom(List.of("alpha", "beta", "gamma"), request))); - add(Prompt.builder("test_prompt_with_embedded_resource") + }).withCompletionHandler((ctx, request) -> completeFrom(List.of("alpha", "beta", "gamma"), request))) + .prompt(McpSyncPrompt.of(Prompt.builder("test_prompt_with_embedded_resource") .description("A prompt with embedded resource for testing") .arguments(List.of(PromptArgument.builder("resourceUri") .description("URI of the resource to embed") .required(true) .build())) - .build(), respond((ctx, req) -> { + .build(), (ctx, req) -> { String resourceUri = req.arguments() == null ? null : req.arguments().get("resourceUri"); EmbeddedResource resource = EmbeddedResource .builder(TextResourceContents.builder(resourceUri, "Embedded resource content for testing.") @@ -651,57 +600,36 @@ private static final class ConformancePrompts implements McpSyncPromptRepository .builder(Role.USER, TextContent.builder("Please process the embedded resource above.").build()) .build()); - })); - add(Prompt.builder("test_prompt_with_image").description("A prompt with image content for testing").build(), - respond((ctx, req) -> messages( + })) + .prompt(McpSyncPrompt.of( + Prompt.builder("test_prompt_with_image") + .description("A prompt with image content for testing") + .build(), + (ctx, req) -> messages( PromptMessage.builder(Role.USER, ImageContent.builder(RED_PIXEL_PNG, "image/png").build()) .build(), PromptMessage .builder(Role.USER, TextContent.builder("Please analyze the image above.").build()) - .build()))); - add(Prompt.builder("test_input_required_result_prompt") + .build()))) + .prompt(McpSyncPrompt.of(Prompt.builder("test_input_required_result_prompt") .description("MRTR: prompt that requires elicitation input") - .build(), ELICITATION, respond((ctx, req) -> { + .build(), ELICITATION, (ctx, req) -> { Map content = acceptedContent(req.inputResponses(), "user_context"); if (content == null || !(content.get("context") instanceof String context)) { - return InputRequiredResult.builder() + return McpSyncResponse.result(InputRequiredResult.builder() .elicit("user_context", elicitString("What context should the prompt use?", "context")) - .build(); + .build()); } return messages(PromptMessage .builder(Role.USER, TextContent.builder("Prompt with context: " + context).build()) .build()); - })); - } - - private void add(Prompt prompt, Handler handler) { - add(prompt, ClientCapabilities.NONE, handler); - } - - private void add(Prompt prompt, ClientCapabilities requiredCapabilities, - Handler handler) { - this.prompts.put(prompt.name(), McpSyncPrompt.of(prompt, requiredCapabilities, handler::handle)); - } - - private static GetPromptResult messages(PromptMessage... messages) { - return GetPromptResult.builder(List.of(messages)).build(); - } - - @Override - public PromptsPage list(McpRequestContext ctx, String cursor) { - return PromptsPage.of(this.prompts.values().stream().map(McpSyncPrompt::definition).toList()); - } - - @Override - public McpSyncPrompt find(McpRequestContext ctx, String name) { - return this.prompts.get(name); - } - - @Override - public boolean supportsCompletion() { - return true; - } + })) + .supportsCompletion(true) + .build(); + } + private static McpSyncResponse messages(PromptMessage... messages) { + return McpSyncResponse.result(GetPromptResult.builder(List.of(messages)).build()); } } diff --git a/docs/server-modern.md b/docs/server-modern.md index 521168ae2..2d677d8d5 100644 --- a/docs/server-modern.md +++ b/docs/server-modern.md @@ -243,6 +243,50 @@ different catalog per tenant or user. Two rules apply to `list`: To paginate, return a page with a `nextCursor`, for example `new ToolsPage(tools, nextCursor, null, null)`. +### In-Memory Repositories + +For a fixed or runtime-managed catalog, the `io.modelcontextprotocol.modern.server.feature.repository` package +implements the repositories for you: `InMemorySyncToolRepository`, `InMemorySyncPromptRepository` and +`InMemorySyncResourceRepository`, and their async counterparts `InMemoryAsyncToolRepository`, +`InMemoryAsyncPromptRepository` and `InMemoryAsyncResourceRepository`, which hold `McpAsyncTool`s, `McpAsyncPrompt`s +and `McpAsyncResource`s and never block. They list entries sorted by name (or URI), page +them when built with `pageSize(...)`, and support adding and removing entries at runtime. When +built with a `changeBroadcaster(...)`, every add and remove is broadcast as the matching +[change notification](#change-notifications): + +```java +McpChangeFeed changes = new McpChangeFeed(); + +InMemorySyncToolRepository tools = InMemorySyncToolRepository.builder() + .tool(weather) + .changeBroadcaster(changes) + .build(); + +InMemorySyncResourceRepository resources = InMemorySyncResourceRepository.builder() + .resource(Resource.builder("file:///config.json", "Configuration").build(), configReader) + .template(McpSyncResourceTemplate.of(ResourceTemplate.builder("file:///logs/{date}", "Daily log").build()), + logReader) + .changeBroadcaster(changes) + .supportsSubscribe(true) + .build(); + +McpServer server = McpServer.builder() + .serverInfo(serverInfo) + .feature(ToolsFeature.ofSync(tools)) + .feature(ResourcesFeature.ofSync(resources)) + .subscriptions(changes) + .build(); + +tools.add(forecast); // broadcasts ToolsListChanged +resources.resourceUpdated("file:///config.json"); // broadcasts ResourceUpdated +``` + +The resource repository pairs each resource and template with the `McpSyncResource` that reads it. A +`resources/read` URI is served by the resource registered under exactly that URI, or else by the first +template, in `uriTemplate` order, that matches it. The prompt and resource repositories serve +[completions](#completions) from the completion handlers of their prompts and templates when built with +`supportsCompletion(true)`. + ### Tools A repository lists tool definitions and finds a tool by name. `find` returns an `McpSyncTool`, which pairs a diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncPromptRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncPromptRepository.java new file mode 100644 index 000000000..a4162a62f --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncPromptRepository.java @@ -0,0 +1,185 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; + +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.feature.McpAsyncCompletionHandler; +import io.modelcontextprotocol.modern.server.feature.McpAsyncPrompt; +import io.modelcontextprotocol.modern.server.feature.McpAsyncPromptRepository; +import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.PromptsPage; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * An {@link McpAsyncPromptRepository} that keeps {@link McpAsyncPrompt}s in memory, keyed + * by name. Prompts can be added and removed at runtime; each change is broadcast as + * {@link ServerChange.PromptsListChanged} when a change broadcaster is configured. + * {@code prompts/list} returns prompts sorted by name, paged when a page size is set. + *

+ * Reading and changing the catalog never blocks. {@code list} and {@code find} read it + * when subscribed to, not when called, and report an invalid cursor as an error signal. + *

+ * Prompt arguments are completed through + * {@link McpAsyncPrompt#withCompletionHandler(McpAsyncCompletionHandler) the prompt's + * completion handler} only when the repository is built with + * {@link Builder#supportsCompletion(boolean) supportsCompletion(true)}. + * + * @author Christian Tzolov + */ +public final class InMemoryAsyncPromptRepository implements McpAsyncPromptRepository { + + private final NavigableMap prompts = new ConcurrentSkipListMap<>(); + + private final McpChangeBroadcaster changeBroadcaster; + + private final Integer pageSize; + + private final boolean supportsCompletion; + + private InMemoryAsyncPromptRepository(Builder builder) { + this.changeBroadcaster = builder.changeBroadcaster; + this.pageSize = builder.pageSize; + this.supportsCompletion = builder.supportsCompletion; + builder.prompts.forEach(this::put); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Adds a prompt and broadcasts {@link ServerChange.PromptsListChanged}. + * @throws IllegalArgumentException if a prompt with the same name exists + */ + public void add(McpAsyncPrompt prompt) { + put(prompt); + broadcastListChanged(); + } + + /** + * Removes a prompt by name and, if it existed, broadcasts + * {@link ServerChange.PromptsListChanged}. + * @return whether a prompt was removed + */ + public boolean remove(String name) { + Assert.hasText(name, "name must not be empty"); + boolean removed = this.prompts.remove(name) != null; + if (removed) { + broadcastListChanged(); + } + return removed; + } + + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.fromSupplier(() -> page(cursor)); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.fromSupplier(() -> this.prompts.get(name)); + } + + private PromptsPage page(String cursor) { + KeyCursorPagination.Page page = KeyCursorPagination.page(this.prompts, McpAsyncPrompt::definition, + cursor, this.pageSize); + return new PromptsPage(page.items(), page.nextCursor(), null, null); + } + + @Override + public boolean supportsCompletion() { + return this.supportsCompletion; + } + + private void put(McpAsyncPrompt prompt) { + Assert.notNull(prompt, "prompt must not be null"); + String name = prompt.definition().name(); + if (this.prompts.putIfAbsent(name, prompt) != null) { + throw new IllegalArgumentException("Prompt with name '" + name + "' already exists"); + } + } + + private void broadcastListChanged() { + if (this.changeBroadcaster != null) { + this.changeBroadcaster.broadcast(new ServerChange.PromptsListChanged()); + } + } + + /** + * Builder for {@link InMemoryAsyncPromptRepository}. + */ + public static final class Builder { + + private final List prompts = new ArrayList<>(); + + private McpChangeBroadcaster changeBroadcaster; + + private Integer pageSize; + + private boolean supportsCompletion; + + private Builder() { + } + + public Builder prompt(McpAsyncPrompt prompt) { + Assert.notNull(prompt, "prompt must not be null"); + this.prompts.add(prompt); + return this; + } + + public Builder prompts(List prompts) { + Assert.notNull(prompts, "prompts must not be null"); + this.prompts.addAll(prompts); + return this; + } + + /** + * Receives a {@link ServerChange.PromptsListChanged} for every add and remove, + * typically an {@link McpChangeFeed} also registered with + * {@code McpServer.Builder#subscriptions}. Unset, changes are not announced. + */ + public Builder changeBroadcaster(McpChangeBroadcaster changeBroadcaster) { + this.changeBroadcaster = changeBroadcaster; + return this; + } + + /** + * Maximum number of prompts per {@code prompts/list} page. Unset, all prompts are + * returned on one page. + */ + public Builder pageSize(Integer pageSize) { + Assert.isTrue(pageSize == null || pageSize > 0, "pageSize must be positive"); + this.pageSize = pageSize; + return this; + } + + /** + * Whether {@code completion/complete} is served for {@code ref/prompt} references + * and {@code completions} is advertised. The server reads this once, when it is + * built, so it is a fixed setting rather than derived from the prompts present: + * prompts with a completion handler may be added later. A prompt without a + * completion handler completes to no values. Defaults to {@code false}. + */ + public Builder supportsCompletion(boolean supportsCompletion) { + this.supportsCompletion = supportsCompletion; + return this; + } + + public InMemoryAsyncPromptRepository build() { + return new InMemoryAsyncPromptRepository(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncResourceRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncResourceRepository.java new file mode 100644 index 000000000..efadd4fb6 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncResourceRepository.java @@ -0,0 +1,339 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; + +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.feature.McpAsyncCompletionHandler; +import io.modelcontextprotocol.modern.server.feature.McpAsyncResource; +import io.modelcontextprotocol.modern.server.feature.McpAsyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.McpAsyncResourceTemplate; +import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.ResourceTemplatesPage; +import io.modelcontextprotocol.modern.server.feature.ResourcesPage; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory; +import io.modelcontextprotocol.util.McpUriTemplateManager; +import io.modelcontextprotocol.util.McpUriTemplateManagerFactory; +import reactor.core.publisher.Mono; + +/** + * An {@link McpAsyncResourceRepository} that keeps resources and resource templates in + * memory, each paired with the {@link McpAsyncResource} that reads it. + *

+ * A {@code resources/read} URI is served by the resource registered under exactly that + * URI, or else by the first template, in {@code uriTemplate} order, whose URI template + * matches it. Resources and templates can be added and removed at runtime; each change is + * broadcast as {@link ServerChange.ResourcesListChanged} when a change broadcaster is + * configured, and {@link #resourceUpdated(String)} broadcasts + * {@link ServerChange.ResourceUpdated}. {@code resources/list} and + * {@code resources/templates/list} return entries sorted by URI and URI template, paged + * when a page size is set. + *

+ * Reading and changing the catalog never blocks. {@code list} and {@code find} read it + * when subscribed to, not when called, and report an invalid cursor as an error signal. + *

+ * Template variables are completed through + * {@link McpAsyncResourceTemplate#withCompletionHandler(McpAsyncCompletionHandler) the + * template's completion handler} only when the repository is built with + * {@link Builder#supportsCompletion(boolean) supportsCompletion(true)}. + * + * @author Christian Tzolov + */ +public final class InMemoryAsyncResourceRepository implements McpAsyncResourceRepository { + + private final NavigableMap resources = new ConcurrentSkipListMap<>(); + + private final NavigableMap templates = new ConcurrentSkipListMap<>(); + + private final McpUriTemplateManagerFactory uriTemplateManagerFactory; + + private final McpChangeBroadcaster changeBroadcaster; + + private final Integer pageSize; + + private final boolean supportsSubscribe; + + private final boolean supportsCompletion; + + private InMemoryAsyncResourceRepository(Builder builder) { + this.uriTemplateManagerFactory = builder.uriTemplateManagerFactory; + this.changeBroadcaster = builder.changeBroadcaster; + this.pageSize = builder.pageSize; + this.supportsSubscribe = builder.supportsSubscribe; + this.supportsCompletion = builder.supportsCompletion; + builder.resources.forEach(resource -> putResource(resource.definition(), resource.reader())); + builder.templates.forEach(template -> putTemplate(template.template(), template.reader())); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Adds a resource read by {@code reader} and broadcasts + * {@link ServerChange.ResourcesListChanged}. + * @throws IllegalArgumentException if a resource with the same URI exists + */ + public void addResource(Resource resource, McpAsyncResource reader) { + putResource(resource, reader); + broadcastListChanged(); + } + + /** + * Adds a resource template whose matching URIs are read by {@code reader}, and + * broadcasts {@link ServerChange.ResourcesListChanged}. + * @throws IllegalArgumentException if a template with the same URI template exists + */ + public void addTemplate(McpAsyncResourceTemplate template, McpAsyncResource reader) { + putTemplate(template, reader); + broadcastListChanged(); + } + + /** + * Removes the resource with this URI and, if it existed, broadcasts + * {@link ServerChange.ResourcesListChanged}. + * @return whether a resource was removed + */ + public boolean removeResource(String uri) { + Assert.hasText(uri, "uri must not be empty"); + return removed(this.resources.remove(uri) != null); + } + + /** + * Removes the resource template with this URI template and, if it existed, broadcasts + * {@link ServerChange.ResourcesListChanged}. + * @return whether a template was removed + */ + public boolean removeTemplate(String uriTemplate) { + Assert.hasText(uriTemplate, "uriTemplate must not be empty"); + return removed(this.templates.remove(uriTemplate) != null); + } + + /** + * Broadcasts {@link ServerChange.ResourceUpdated} for {@code uri}, telling + * subscribers that its content changed. The server forwards it only when the + * repository is built with {@link Builder#supportsSubscribe(boolean) + * supportsSubscribe(true)}. + */ + public void resourceUpdated(String uri) { + Assert.hasText(uri, "uri must not be empty"); + if (this.changeBroadcaster != null) { + this.changeBroadcaster.broadcast(new ServerChange.ResourceUpdated(uri)); + } + } + + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.fromSupplier(() -> page(cursor)); + } + + @Override + public Mono listTemplates(McpRequestContext ctx, String cursor) { + return Mono.fromSupplier(() -> templatesPage(cursor)); + } + + @Override + public Mono find(McpRequestContext ctx, String uri) { + return Mono.fromSupplier(() -> reader(uri)); + } + + @Override + public Mono findTemplate(McpRequestContext ctx, String uriTemplate) { + return Mono.fromSupplier(() -> this.templates.get(uriTemplate)).map(TemplatedResource::template); + } + + @Override + public boolean supportsSubscribe() { + return this.supportsSubscribe; + } + + @Override + public boolean supportsCompletion() { + return this.supportsCompletion; + } + + private ResourcesPage page(String cursor) { + KeyCursorPagination.Page page = KeyCursorPagination.page(this.resources, StaticResource::definition, + cursor, this.pageSize); + return new ResourcesPage(page.items(), page.nextCursor(), null, null); + } + + private ResourceTemplatesPage templatesPage(String cursor) { + KeyCursorPagination.Page page = KeyCursorPagination.page(this.templates, + template -> template.template().definition(), cursor, this.pageSize); + return new ResourceTemplatesPage(page.items(), page.nextCursor(), null, null); + } + + /** + * The reader of the resource registered under exactly {@code uri}, or else of the + * first template matching it, or {@code null} if there is none. + */ + private McpAsyncResource reader(String uri) { + StaticResource resource = this.resources.get(uri); + if (resource != null) { + return resource.reader(); + } + for (TemplatedResource template : this.templates.values()) { + if (template.matcher().matches(uri)) { + return template.reader(); + } + } + return null; + } + + private void putResource(Resource resource, McpAsyncResource reader) { + Assert.notNull(resource, "resource must not be null"); + Assert.notNull(reader, "reader must not be null"); + String uri = resource.uri(); + if (this.resources.putIfAbsent(uri, new StaticResource(resource, reader)) != null) { + throw new IllegalArgumentException("Resource with URI '" + uri + "' already exists"); + } + } + + private void putTemplate(McpAsyncResourceTemplate template, McpAsyncResource reader) { + Assert.notNull(template, "template must not be null"); + Assert.notNull(reader, "reader must not be null"); + String uriTemplate = template.definition().uriTemplate(); + // The matcher is created once per template rather than on every resources/read. + TemplatedResource compiled = new TemplatedResource(template, reader, + this.uriTemplateManagerFactory.create(uriTemplate)); + if (this.templates.putIfAbsent(uriTemplate, compiled) != null) { + throw new IllegalArgumentException("Resource template with URI '" + uriTemplate + "' already exists"); + } + } + + private boolean removed(boolean removed) { + if (removed) { + broadcastListChanged(); + } + return removed; + } + + private void broadcastListChanged() { + if (this.changeBroadcaster != null) { + this.changeBroadcaster.broadcast(new ServerChange.ResourcesListChanged()); + } + } + + private record StaticResource(Resource definition, McpAsyncResource reader) { + } + + private record TemplatedResource(McpAsyncResourceTemplate template, McpAsyncResource reader, + McpUriTemplateManager matcher) { + } + + private record PendingTemplate(McpAsyncResourceTemplate template, McpAsyncResource reader) { + } + + /** + * Builder for {@link InMemoryAsyncResourceRepository}. + */ + public static final class Builder { + + private final List resources = new ArrayList<>(); + + private final List templates = new ArrayList<>(); + + private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory(); + + private McpChangeBroadcaster changeBroadcaster; + + private Integer pageSize; + + private boolean supportsSubscribe; + + private boolean supportsCompletion; + + private Builder() { + } + + /** Adds a resource read by {@code reader}. */ + public Builder resource(Resource resource, McpAsyncResource reader) { + Assert.notNull(resource, "resource must not be null"); + Assert.notNull(reader, "reader must not be null"); + this.resources.add(new StaticResource(resource, reader)); + return this; + } + + /** Adds a resource template whose matching URIs are read by {@code reader}. */ + public Builder template(McpAsyncResourceTemplate template, McpAsyncResource reader) { + Assert.notNull(template, "template must not be null"); + Assert.notNull(reader, "reader must not be null"); + this.templates.add(new PendingTemplate(template, reader)); + return this; + } + + /** + * Compiles the URI templates that {@code resources/read} URIs are matched + * against. Defaults to {@link DefaultMcpUriTemplateManagerFactory}. + */ + public Builder uriTemplateManagerFactory(McpUriTemplateManagerFactory uriTemplateManagerFactory) { + Assert.notNull(uriTemplateManagerFactory, "uriTemplateManagerFactory must not be null"); + this.uriTemplateManagerFactory = uriTemplateManagerFactory; + return this; + } + + /** + * Receives a {@link ServerChange.ResourcesListChanged} for every add and remove, + * and the {@link ServerChange.ResourceUpdated} of + * {@link InMemoryAsyncResourceRepository#resourceUpdated(String)}, typically an + * {@link McpChangeFeed} also registered with + * {@code McpServer.Builder#subscriptions}. Unset, changes are not announced. + */ + public Builder changeBroadcaster(McpChangeBroadcaster changeBroadcaster) { + this.changeBroadcaster = changeBroadcaster; + return this; + } + + /** + * Maximum number of entries per {@code resources/list} and + * {@code resources/templates/list} page. Unset, everything is returned on one + * page. + */ + public Builder pageSize(Integer pageSize) { + Assert.isTrue(pageSize == null || pageSize > 0, "pageSize must be positive"); + this.pageSize = pageSize; + return this; + } + + /** + * Whether {@code resources.subscribe} is advertised, so that + * {@link InMemoryAsyncResourceRepository#resourceUpdated(String)} reaches + * subscribers. Defaults to {@code false}. + */ + public Builder supportsSubscribe(boolean supportsSubscribe) { + this.supportsSubscribe = supportsSubscribe; + return this; + } + + /** + * Whether {@code completion/complete} is served for {@code ref/resource} + * references and {@code completions} is advertised. The server reads this once, + * when it is built, so it is a fixed setting rather than derived from the + * templates present: templates with a completion handler may be added later. A + * template without a completion handler completes to no values. Defaults to + * {@code false}. + */ + public Builder supportsCompletion(boolean supportsCompletion) { + this.supportsCompletion = supportsCompletion; + return this; + } + + public InMemoryAsyncResourceRepository build() { + return new InMemoryAsyncResourceRepository(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncToolRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncToolRepository.java new file mode 100644 index 000000000..0968c504d --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemoryAsyncToolRepository.java @@ -0,0 +1,157 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; + +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.feature.McpAsyncTool; +import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; +import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * An {@link McpAsyncToolRepository} that keeps {@link McpAsyncTool}s in memory, keyed by + * name. Tools can be added and removed at runtime; each change is broadcast as + * {@link ServerChange.ToolsListChanged} when a change broadcaster is configured. + * {@code tools/list} returns tools sorted by name, paged when a page size is set. + *

+ * Reading and changing the catalog never blocks. {@code list} and {@code find} read it + * when subscribed to, not when called, and report an invalid cursor as an error signal. + * + * @author Christian Tzolov + */ +public final class InMemoryAsyncToolRepository implements McpAsyncToolRepository { + + private final NavigableMap tools = new ConcurrentSkipListMap<>(); + + private final McpChangeBroadcaster changeBroadcaster; + + private final Integer pageSize; + + private InMemoryAsyncToolRepository(Builder builder) { + this.changeBroadcaster = builder.changeBroadcaster; + this.pageSize = builder.pageSize; + builder.tools.forEach(this::put); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Adds a tool and broadcasts {@link ServerChange.ToolsListChanged}. + * @throws IllegalArgumentException if a tool with the same name exists + */ + public void add(McpAsyncTool tool) { + put(tool); + broadcastListChanged(); + } + + /** + * Removes a tool by name and, if it existed, broadcasts + * {@link ServerChange.ToolsListChanged}. + * @return whether a tool was removed + */ + public boolean remove(String name) { + Assert.hasText(name, "name must not be empty"); + boolean removed = this.tools.remove(name) != null; + if (removed) { + broadcastListChanged(); + } + return removed; + } + + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.fromSupplier(() -> page(cursor)); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.fromSupplier(() -> this.tools.get(name)); + } + + private ToolsPage page(String cursor) { + KeyCursorPagination.Page page = KeyCursorPagination.page(this.tools, McpAsyncTool::definition, cursor, + this.pageSize); + return new ToolsPage(page.items(), page.nextCursor(), null, null); + } + + private void put(McpAsyncTool tool) { + Assert.notNull(tool, "tool must not be null"); + String name = tool.definition().name(); + if (this.tools.putIfAbsent(name, tool) != null) { + throw new IllegalArgumentException("Tool with name '" + name + "' already exists"); + } + } + + private void broadcastListChanged() { + if (this.changeBroadcaster != null) { + this.changeBroadcaster.broadcast(new ServerChange.ToolsListChanged()); + } + } + + /** + * Builder for {@link InMemoryAsyncToolRepository}. + */ + public static final class Builder { + + private final List tools = new ArrayList<>(); + + private McpChangeBroadcaster changeBroadcaster; + + private Integer pageSize; + + private Builder() { + } + + public Builder tool(McpAsyncTool tool) { + Assert.notNull(tool, "tool must not be null"); + this.tools.add(tool); + return this; + } + + public Builder tools(List tools) { + Assert.notNull(tools, "tools must not be null"); + this.tools.addAll(tools); + return this; + } + + /** + * Receives a {@link ServerChange.ToolsListChanged} for every add and remove, + * typically an {@link McpChangeFeed} also registered with + * {@code McpServer.Builder#subscriptions}. Unset, changes are not announced. + */ + public Builder changeBroadcaster(McpChangeBroadcaster changeBroadcaster) { + this.changeBroadcaster = changeBroadcaster; + return this; + } + + /** + * Maximum number of tools per {@code tools/list} page. Unset, all tools are + * returned on one page. + */ + public Builder pageSize(Integer pageSize) { + Assert.isTrue(pageSize == null || pageSize > 0, "pageSize must be positive"); + this.pageSize = pageSize; + return this; + } + + public InMemoryAsyncToolRepository build() { + return new InMemoryAsyncToolRepository(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncPromptRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncPromptRepository.java new file mode 100644 index 000000000..29323cecc --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncPromptRepository.java @@ -0,0 +1,177 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; + +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpSyncCompletionHandler; +import io.modelcontextprotocol.modern.server.feature.McpSyncPrompt; +import io.modelcontextprotocol.modern.server.feature.McpSyncPromptRepository; +import io.modelcontextprotocol.modern.server.feature.PromptsPage; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.util.Assert; + +/** + * An {@link McpSyncPromptRepository} that keeps {@link McpSyncPrompt}s in memory, keyed + * by name. Prompts can be added and removed at runtime; each change is broadcast as + * {@link ServerChange.PromptsListChanged} when a change broadcaster is configured. + * {@code prompts/list} returns prompts sorted by name, paged when a page size is set. + *

+ * Prompt arguments are completed through + * {@link McpSyncPrompt#withCompletionHandler(McpSyncCompletionHandler) the prompt's + * completion handler} only when the repository is built with + * {@link Builder#supportsCompletion(boolean) supportsCompletion(true)}. + * + * @author Christian Tzolov + */ +public final class InMemorySyncPromptRepository implements McpSyncPromptRepository { + + private final NavigableMap prompts = new ConcurrentSkipListMap<>(); + + private final McpChangeBroadcaster changeBroadcaster; + + private final Integer pageSize; + + private final boolean supportsCompletion; + + private InMemorySyncPromptRepository(Builder builder) { + this.changeBroadcaster = builder.changeBroadcaster; + this.pageSize = builder.pageSize; + this.supportsCompletion = builder.supportsCompletion; + builder.prompts.forEach(this::put); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Adds a prompt and broadcasts {@link ServerChange.PromptsListChanged}. + * @throws IllegalArgumentException if a prompt with the same name exists + */ + public void add(McpSyncPrompt prompt) { + put(prompt); + broadcastListChanged(); + } + + /** + * Removes a prompt by name and, if it existed, broadcasts + * {@link ServerChange.PromptsListChanged}. + * @return whether a prompt was removed + */ + public boolean remove(String name) { + Assert.hasText(name, "name must not be empty"); + boolean removed = this.prompts.remove(name) != null; + if (removed) { + broadcastListChanged(); + } + return removed; + } + + @Override + public PromptsPage list(McpRequestContext ctx, String cursor) { + KeyCursorPagination.Page page = KeyCursorPagination.page(this.prompts, McpSyncPrompt::definition, + cursor, this.pageSize); + return new PromptsPage(page.items(), page.nextCursor(), null, null); + } + + @Override + public McpSyncPrompt find(McpRequestContext ctx, String name) { + return this.prompts.get(name); + } + + @Override + public boolean supportsCompletion() { + return this.supportsCompletion; + } + + private void put(McpSyncPrompt prompt) { + Assert.notNull(prompt, "prompt must not be null"); + String name = prompt.definition().name(); + if (this.prompts.putIfAbsent(name, prompt) != null) { + throw new IllegalArgumentException("Prompt with name '" + name + "' already exists"); + } + } + + private void broadcastListChanged() { + if (this.changeBroadcaster != null) { + this.changeBroadcaster.broadcast(new ServerChange.PromptsListChanged()); + } + } + + /** + * Builder for {@link InMemorySyncPromptRepository}. + */ + public static final class Builder { + + private final List prompts = new ArrayList<>(); + + private McpChangeBroadcaster changeBroadcaster; + + private Integer pageSize; + + private boolean supportsCompletion; + + private Builder() { + } + + public Builder prompt(McpSyncPrompt prompt) { + Assert.notNull(prompt, "prompt must not be null"); + this.prompts.add(prompt); + return this; + } + + public Builder prompts(List prompts) { + Assert.notNull(prompts, "prompts must not be null"); + this.prompts.addAll(prompts); + return this; + } + + /** + * Receives a {@link ServerChange.PromptsListChanged} for every add and remove, + * typically an {@link McpChangeFeed} also registered with + * {@code McpServer.Builder#subscriptions}. Unset, changes are not announced. + */ + public Builder changeBroadcaster(McpChangeBroadcaster changeBroadcaster) { + this.changeBroadcaster = changeBroadcaster; + return this; + } + + /** + * Maximum number of prompts per {@code prompts/list} page. Unset, all prompts are + * returned on one page. + */ + public Builder pageSize(Integer pageSize) { + Assert.isTrue(pageSize == null || pageSize > 0, "pageSize must be positive"); + this.pageSize = pageSize; + return this; + } + + /** + * Whether {@code completion/complete} is served for {@code ref/prompt} references + * and {@code completions} is advertised. The server reads this once, when it is + * built, so it is a fixed setting rather than derived from the prompts present: + * prompts with a completion handler may be added later. A prompt without a + * completion handler completes to no values. Defaults to {@code false}. + */ + public Builder supportsCompletion(boolean supportsCompletion) { + this.supportsCompletion = supportsCompletion; + return this; + } + + public InMemorySyncPromptRepository build() { + return new InMemorySyncPromptRepository(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncResourceRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncResourceRepository.java new file mode 100644 index 000000000..52c6b0b27 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncResourceRepository.java @@ -0,0 +1,320 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; + +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpSyncCompletionHandler; +import io.modelcontextprotocol.modern.server.feature.McpSyncResource; +import io.modelcontextprotocol.modern.server.feature.McpSyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.McpSyncResourceTemplate; +import io.modelcontextprotocol.modern.server.feature.ResourceTemplatesPage; +import io.modelcontextprotocol.modern.server.feature.ResourcesPage; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory; +import io.modelcontextprotocol.util.McpUriTemplateManager; +import io.modelcontextprotocol.util.McpUriTemplateManagerFactory; + +/** + * An {@link McpSyncResourceRepository} that keeps resources and resource templates in + * memory, each paired with the {@link McpSyncResource} that reads it. + *

+ * A {@code resources/read} URI is served by the resource registered under exactly that + * URI, or else by the first template, in {@code uriTemplate} order, whose URI template + * matches it. Resources and templates can be added and removed at runtime; each change is + * broadcast as {@link ServerChange.ResourcesListChanged} when a change broadcaster is + * configured, and {@link #resourceUpdated(String)} broadcasts + * {@link ServerChange.ResourceUpdated}. {@code resources/list} and + * {@code resources/templates/list} return entries sorted by URI and URI template, paged + * when a page size is set. + *

+ * Template variables are completed through + * {@link McpSyncResourceTemplate#withCompletionHandler(McpSyncCompletionHandler) the + * template's completion handler} only when the repository is built with + * {@link Builder#supportsCompletion(boolean) supportsCompletion(true)}. + * + * @author Christian Tzolov + */ +public final class InMemorySyncResourceRepository implements McpSyncResourceRepository { + + private final NavigableMap resources = new ConcurrentSkipListMap<>(); + + private final NavigableMap templates = new ConcurrentSkipListMap<>(); + + private final McpUriTemplateManagerFactory uriTemplateManagerFactory; + + private final McpChangeBroadcaster changeBroadcaster; + + private final Integer pageSize; + + private final boolean supportsSubscribe; + + private final boolean supportsCompletion; + + private InMemorySyncResourceRepository(Builder builder) { + this.uriTemplateManagerFactory = builder.uriTemplateManagerFactory; + this.changeBroadcaster = builder.changeBroadcaster; + this.pageSize = builder.pageSize; + this.supportsSubscribe = builder.supportsSubscribe; + this.supportsCompletion = builder.supportsCompletion; + builder.resources.forEach(resource -> putResource(resource.definition(), resource.reader())); + builder.templates.forEach(template -> putTemplate(template.template(), template.reader())); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Adds a resource read by {@code reader} and broadcasts + * {@link ServerChange.ResourcesListChanged}. + * @throws IllegalArgumentException if a resource with the same URI exists + */ + public void addResource(Resource resource, McpSyncResource reader) { + putResource(resource, reader); + broadcastListChanged(); + } + + /** + * Adds a resource template whose matching URIs are read by {@code reader}, and + * broadcasts {@link ServerChange.ResourcesListChanged}. + * @throws IllegalArgumentException if a template with the same URI template exists + */ + public void addTemplate(McpSyncResourceTemplate template, McpSyncResource reader) { + putTemplate(template, reader); + broadcastListChanged(); + } + + /** + * Removes the resource with this URI and, if it existed, broadcasts + * {@link ServerChange.ResourcesListChanged}. + * @return whether a resource was removed + */ + public boolean removeResource(String uri) { + Assert.hasText(uri, "uri must not be empty"); + return removed(this.resources.remove(uri) != null); + } + + /** + * Removes the resource template with this URI template and, if it existed, broadcasts + * {@link ServerChange.ResourcesListChanged}. + * @return whether a template was removed + */ + public boolean removeTemplate(String uriTemplate) { + Assert.hasText(uriTemplate, "uriTemplate must not be empty"); + return removed(this.templates.remove(uriTemplate) != null); + } + + /** + * Broadcasts {@link ServerChange.ResourceUpdated} for {@code uri}, telling + * subscribers that its content changed. The server forwards it only when the + * repository is built with {@link Builder#supportsSubscribe(boolean) + * supportsSubscribe(true)}. + */ + public void resourceUpdated(String uri) { + Assert.hasText(uri, "uri must not be empty"); + if (this.changeBroadcaster != null) { + this.changeBroadcaster.broadcast(new ServerChange.ResourceUpdated(uri)); + } + } + + @Override + public ResourcesPage list(McpRequestContext ctx, String cursor) { + KeyCursorPagination.Page page = KeyCursorPagination.page(this.resources, StaticResource::definition, + cursor, this.pageSize); + return new ResourcesPage(page.items(), page.nextCursor(), null, null); + } + + @Override + public ResourceTemplatesPage listTemplates(McpRequestContext ctx, String cursor) { + KeyCursorPagination.Page page = KeyCursorPagination.page(this.templates, + template -> template.template().definition(), cursor, this.pageSize); + return new ResourceTemplatesPage(page.items(), page.nextCursor(), null, null); + } + + @Override + public McpSyncResource find(McpRequestContext ctx, String uri) { + StaticResource resource = this.resources.get(uri); + if (resource != null) { + return resource.reader(); + } + for (TemplatedResource template : this.templates.values()) { + if (template.matcher().matches(uri)) { + return template.reader(); + } + } + return null; + } + + @Override + public McpSyncResourceTemplate findTemplate(McpRequestContext ctx, String uriTemplate) { + TemplatedResource template = this.templates.get(uriTemplate); + return template != null ? template.template() : null; + } + + @Override + public boolean supportsSubscribe() { + return this.supportsSubscribe; + } + + @Override + public boolean supportsCompletion() { + return this.supportsCompletion; + } + + private void putResource(Resource resource, McpSyncResource reader) { + Assert.notNull(resource, "resource must not be null"); + Assert.notNull(reader, "reader must not be null"); + String uri = resource.uri(); + if (this.resources.putIfAbsent(uri, new StaticResource(resource, reader)) != null) { + throw new IllegalArgumentException("Resource with URI '" + uri + "' already exists"); + } + } + + private void putTemplate(McpSyncResourceTemplate template, McpSyncResource reader) { + Assert.notNull(template, "template must not be null"); + Assert.notNull(reader, "reader must not be null"); + String uriTemplate = template.definition().uriTemplate(); + // The matcher is created once per template rather than on every resources/read. + TemplatedResource compiled = new TemplatedResource(template, reader, + this.uriTemplateManagerFactory.create(uriTemplate)); + if (this.templates.putIfAbsent(uriTemplate, compiled) != null) { + throw new IllegalArgumentException("Resource template with URI '" + uriTemplate + "' already exists"); + } + } + + private boolean removed(boolean removed) { + if (removed) { + broadcastListChanged(); + } + return removed; + } + + private void broadcastListChanged() { + if (this.changeBroadcaster != null) { + this.changeBroadcaster.broadcast(new ServerChange.ResourcesListChanged()); + } + } + + private record StaticResource(Resource definition, McpSyncResource reader) { + } + + private record TemplatedResource(McpSyncResourceTemplate template, McpSyncResource reader, + McpUriTemplateManager matcher) { + } + + private record PendingTemplate(McpSyncResourceTemplate template, McpSyncResource reader) { + } + + /** + * Builder for {@link InMemorySyncResourceRepository}. + */ + public static final class Builder { + + private final List resources = new ArrayList<>(); + + private final List templates = new ArrayList<>(); + + private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory(); + + private McpChangeBroadcaster changeBroadcaster; + + private Integer pageSize; + + private boolean supportsSubscribe; + + private boolean supportsCompletion; + + private Builder() { + } + + /** Adds a resource read by {@code reader}. */ + public Builder resource(Resource resource, McpSyncResource reader) { + Assert.notNull(resource, "resource must not be null"); + Assert.notNull(reader, "reader must not be null"); + this.resources.add(new StaticResource(resource, reader)); + return this; + } + + /** Adds a resource template whose matching URIs are read by {@code reader}. */ + public Builder template(McpSyncResourceTemplate template, McpSyncResource reader) { + Assert.notNull(template, "template must not be null"); + Assert.notNull(reader, "reader must not be null"); + this.templates.add(new PendingTemplate(template, reader)); + return this; + } + + /** + * Compiles the URI templates that {@code resources/read} URIs are matched + * against. Defaults to {@link DefaultMcpUriTemplateManagerFactory}. + */ + public Builder uriTemplateManagerFactory(McpUriTemplateManagerFactory uriTemplateManagerFactory) { + Assert.notNull(uriTemplateManagerFactory, "uriTemplateManagerFactory must not be null"); + this.uriTemplateManagerFactory = uriTemplateManagerFactory; + return this; + } + + /** + * Receives a {@link ServerChange.ResourcesListChanged} for every add and remove, + * and the {@link ServerChange.ResourceUpdated} of + * {@link InMemorySyncResourceRepository#resourceUpdated(String)}, typically an + * {@link McpChangeFeed} also registered with + * {@code McpServer.Builder#subscriptions}. Unset, changes are not announced. + */ + public Builder changeBroadcaster(McpChangeBroadcaster changeBroadcaster) { + this.changeBroadcaster = changeBroadcaster; + return this; + } + + /** + * Maximum number of entries per {@code resources/list} and + * {@code resources/templates/list} page. Unset, everything is returned on one + * page. + */ + public Builder pageSize(Integer pageSize) { + Assert.isTrue(pageSize == null || pageSize > 0, "pageSize must be positive"); + this.pageSize = pageSize; + return this; + } + + /** + * Whether {@code resources.subscribe} is advertised, so that + * {@link InMemorySyncResourceRepository#resourceUpdated(String)} reaches + * subscribers. Defaults to {@code false}. + */ + public Builder supportsSubscribe(boolean supportsSubscribe) { + this.supportsSubscribe = supportsSubscribe; + return this; + } + + /** + * Whether {@code completion/complete} is served for {@code ref/resource} + * references and {@code completions} is advertised. The server reads this once, + * when it is built, so it is a fixed setting rather than derived from the + * templates present: templates with a completion handler may be added later. A + * template without a completion handler completes to no values. Defaults to + * {@code false}. + */ + public Builder supportsCompletion(boolean supportsCompletion) { + this.supportsCompletion = supportsCompletion; + return this; + } + + public InMemorySyncResourceRepository build() { + return new InMemorySyncResourceRepository(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncToolRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncToolRepository.java new file mode 100644 index 000000000..e211782ec --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/InMemorySyncToolRepository.java @@ -0,0 +1,149 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.NavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; + +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpRequestContext; +import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpSyncTool; +import io.modelcontextprotocol.modern.server.feature.McpSyncToolRepository; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.util.Assert; + +/** + * An {@link McpSyncToolRepository} that keeps {@link McpSyncTool}s in memory, keyed by + * name. Tools can be added and removed at runtime; each change is broadcast as + * {@link ServerChange.ToolsListChanged} when a change broadcaster is configured. + * {@code tools/list} returns tools sorted by name, paged when a page size is set. + * + * @author Christian Tzolov + */ +public final class InMemorySyncToolRepository implements McpSyncToolRepository { + + private final NavigableMap tools = new ConcurrentSkipListMap<>(); + + private final McpChangeBroadcaster changeBroadcaster; + + private final Integer pageSize; + + private InMemorySyncToolRepository(Builder builder) { + this.changeBroadcaster = builder.changeBroadcaster; + this.pageSize = builder.pageSize; + builder.tools.forEach(this::put); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Adds a tool and broadcasts {@link ServerChange.ToolsListChanged}. + * @throws IllegalArgumentException if a tool with the same name exists + */ + public void add(McpSyncTool tool) { + put(tool); + broadcastListChanged(); + } + + /** + * Removes a tool by name and, if it existed, broadcasts + * {@link ServerChange.ToolsListChanged}. + * @return whether a tool was removed + */ + public boolean remove(String name) { + Assert.hasText(name, "name must not be empty"); + boolean removed = this.tools.remove(name) != null; + if (removed) { + broadcastListChanged(); + } + return removed; + } + + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + KeyCursorPagination.Page page = KeyCursorPagination.page(this.tools, McpSyncTool::definition, cursor, + this.pageSize); + return new ToolsPage(page.items(), page.nextCursor(), null, null); + } + + @Override + public McpSyncTool find(McpRequestContext ctx, String name) { + return this.tools.get(name); + } + + private void put(McpSyncTool tool) { + Assert.notNull(tool, "tool must not be null"); + String name = tool.definition().name(); + if (this.tools.putIfAbsent(name, tool) != null) { + throw new IllegalArgumentException("Tool with name '" + name + "' already exists"); + } + } + + private void broadcastListChanged() { + if (this.changeBroadcaster != null) { + this.changeBroadcaster.broadcast(new ServerChange.ToolsListChanged()); + } + } + + /** + * Builder for {@link InMemorySyncToolRepository}. + */ + public static final class Builder { + + private final List tools = new ArrayList<>(); + + private McpChangeBroadcaster changeBroadcaster; + + private Integer pageSize; + + private Builder() { + } + + public Builder tool(McpSyncTool tool) { + Assert.notNull(tool, "tool must not be null"); + this.tools.add(tool); + return this; + } + + public Builder tools(List tools) { + Assert.notNull(tools, "tools must not be null"); + this.tools.addAll(tools); + return this; + } + + /** + * Receives a {@link ServerChange.ToolsListChanged} for every add and remove, + * typically an {@link McpChangeFeed} also registered with + * {@code McpServer.Builder#subscriptions}. Unset, changes are not announced. + */ + public Builder changeBroadcaster(McpChangeBroadcaster changeBroadcaster) { + this.changeBroadcaster = changeBroadcaster; + return this; + } + + /** + * Maximum number of tools per {@code tools/list} page. Unset, all tools are + * returned on one page. + */ + public Builder pageSize(Integer pageSize) { + Assert.isTrue(pageSize == null || pageSize > 0, "pageSize must be positive"); + this.pageSize = pageSize; + return this; + } + + public InMemorySyncToolRepository build() { + return new InMemorySyncToolRepository(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/KeyCursorPagination.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/KeyCursorPagination.java new file mode 100644 index 000000000..c8721da12 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/repository/KeyCursorPagination.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repository; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.function.Function; + +import io.modelcontextprotocol.modern.McpException; + +/** + * Pages the entries of a sorted map, in ascending key order. The cursor is the opaque, + * encoded key of the last entry of the previous page, so a page stays correct when + * entries are added or removed between two requests: the next page starts at the first + * key after the cursor, whether or not that key still exists. + * + * @author Christian Tzolov + */ +final class KeyCursorPagination { + + // Distinguishes cursors produced here from arbitrary base64 input. + private static final String PREFIX = "k:"; + + private KeyCursorPagination() { + } + + /** + * Returns the page of {@code entries} following {@code cursor}, with every value + * mapped through {@code item}. + *

+ * The page starts with a {@code tailMap} seek to the cursor key rather than a scan + * from the first entry, so walking a list of {@code n} entries page by page costs + * O(n) overall instead of O(n²/pageSize). + * @param entries the entries, keyed by the value the cursor encodes + * @param pageSize {@code null} returns everything on one page; any cursor is then + * invalid, since this method never produced one + * @throws McpException invalid params when the cursor is not one this method produced + */ + static Page page(NavigableMap entries, Function item, String cursor, Integer pageSize) { + if (pageSize == null && cursor != null) { + throw invalidCursor(cursor); + } + NavigableMap remaining = cursor != null ? entries.tailMap(decode(cursor), false) : entries; + List items = new ArrayList<>(); + String last = null; + boolean more = false; + for (Map.Entry entry : remaining.entrySet()) { + if (pageSize != null && items.size() == pageSize) { + more = true; + break; + } + items.add(item.apply(entry.getValue())); + last = entry.getKey(); + } + return new Page<>(List.copyOf(items), more ? encode(last) : null); + } + + static String encode(String lastKey) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString((PREFIX + lastKey).getBytes(StandardCharsets.UTF_8)); + } + + static String decode(String cursor) { + String decoded; + try { + decoded = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + } + catch (IllegalArgumentException e) { + throw invalidCursor(cursor); + } + if (!decoded.startsWith(PREFIX)) { + throw invalidCursor(cursor); + } + return decoded.substring(PREFIX.length()); + } + + private static McpException invalidCursor(String cursor) { + return McpException.invalidParams("Invalid cursor: " + cursor); + } + + record Page(List items, String nextCursor) { + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java index 105f92bd0..1be715ecb 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java @@ -23,6 +23,7 @@ import io.modelcontextprotocol.modern.McpSchema.InputRequiredResult; import io.modelcontextprotocol.modern.McpSchema.MetaKeys; import io.modelcontextprotocol.modern.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.modern.McpSchema.Resource; import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.TextContent; import io.modelcontextprotocol.modern.McpSchema.TextResourceContents; @@ -30,8 +31,8 @@ import io.modelcontextprotocol.modern.server.feature.McpAsyncResource; import io.modelcontextprotocol.modern.server.feature.McpAsyncResourceRepository; import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; -import io.modelcontextprotocol.modern.server.feature.ResourcesPage; import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncResourceRepository; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -216,22 +217,11 @@ void inputRequestsForDeclaredCapabilitiesAreSent() { @Test void resourceInputRequestsAreCheckedToo() { - McpAsyncResourceRepository repo = new McpAsyncResourceRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ResourcesPage.of(List.of())); - } - - @Override - public Mono find(McpRequestContext ctx, String uri) { - return Mono.just(McpAsyncResource.of(this::read)); - } - - private Mono> read(McpRequestContext ctx, - McpSchema.ReadResourceRequest request) { - return Mono.just(McpAsyncResponse.result(InputRequiredResult.builder().listRoots("q1").build())); - } - }; + McpAsyncResourceRepository repo = InMemoryAsyncResourceRepository.builder() + .resource(Resource.builder("file:///a.txt", "a").build(), + McpAsyncResource.of((ctx, request) -> Mono + .just(McpAsyncResponse.result(InputRequiredResult.builder().listRoots("q1").build())))) + .build(); McpServer server = baseBuilder() .feature(ResourcesFeature.ofAsync(repo, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) .build(); @@ -245,26 +235,14 @@ private Mono> read(McpRequestCon @Test void retryResultIsMarkedUncacheable() { - McpAsyncResourceRepository repo = new McpAsyncResourceRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ResourcesPage.of(List.of())); - } - - @Override - public Mono find(McpRequestContext ctx, String uri) { - return Mono.just(McpAsyncResource.of(this::read)); - } - - private Mono> read(McpRequestContext ctx, - McpSchema.ReadResourceRequest request) { - return Mono.just(McpAsyncResponse.result(ReadResourceResult - .builder(List.of(new TextResourceContents(request.uri(), "text/plain", "content", null))) - .ttlMs(60_000L) - .cacheScope(CacheScope.PUBLIC) - .build())); - } - }; + McpAsyncResourceRepository repo = InMemoryAsyncResourceRepository.builder() + .resource(Resource.builder("file:///a.txt", "a").build(), + McpAsyncResource.of((ctx, request) -> Mono.just(McpAsyncResponse.result(ReadResourceResult + .builder(List.of(new TextResourceContents(request.uri(), "text/plain", "content", null))) + .ttlMs(60_000L) + .cacheScope(CacheScope.PUBLIC) + .build())))) + .build(); McpServer server = baseBuilder() .feature(ResourcesFeature.ofAsync(repo, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) .build(); @@ -285,27 +263,20 @@ private Mono> read(McpRequestCon @SuppressWarnings("unchecked") void resourceRequestStateIsBoundToTheUriNotAStrayName() { AtomicReference seenRequestState = new AtomicReference<>(); - McpAsyncResourceRepository repo = new McpAsyncResourceRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ResourcesPage.of(List.of())); - } - - @Override - public Mono find(McpRequestContext ctx, String uri) { - return Mono.just(McpAsyncResource.of(this::read)); - } - - private Mono> read(McpRequestContext ctx, - McpSchema.ReadResourceRequest request) { - if (request.requestState() != null) { - seenRequestState.set(request.requestState()); - return Mono.just(McpAsyncResponse.result(ReadResourceResult.builder(List.of()).build())); - } - return Mono.just(McpAsyncResponse - .result(InputRequiredResult.builder().elicit("q1", CONFIRM).requestState("for-a").build())); + McpAsyncResource reader = McpAsyncResource.of((ctx, request) -> { + if (request.requestState() != null) { + seenRequestState.set(request.requestState()); + return Mono.just(McpAsyncResponse.result(ReadResourceResult.builder(List.of()).build())); } - }; + return Mono.just(McpAsyncResponse + .result(InputRequiredResult.builder().elicit("q1", CONFIRM).requestState("for-a").build())); + }); + // Both URIs exist, so the replay against b.txt is rejected for its requestState + // being sealed for a.txt, not for naming an unknown resource. + McpAsyncResourceRepository repo = InMemoryAsyncResourceRepository.builder() + .resource(Resource.builder("file:///a.txt", "a").build(), reader) + .resource(Resource.builder("file:///b.txt", "b").build(), reader) + .build(); McpServer server = baseBuilder() .feature(ResourcesFeature.ofAsync(repo, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) .build(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java index d30e7c565..9ababb3e3 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java @@ -24,6 +24,7 @@ import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; import io.modelcontextprotocol.modern.server.feature.ToolsPage; import io.modelcontextprotocol.modern.server.feature.McpAsyncTool; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncToolRepository; import io.modelcontextprotocol.util.ToolsUtils; import reactor.core.publisher.Mono; @@ -110,17 +111,7 @@ public Mono find(McpRequestContext ctx, String name) { /** A tool repository with no tools. */ public static McpAsyncToolRepository emptyTools() { - return new McpAsyncToolRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ToolsPage.of(List.of())); - } - - @Override - public Mono find(McpRequestContext ctx, String name) { - return Mono.empty(); - } - }; + return InMemoryAsyncToolRepository.builder().build(); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java index b5172d1a4..c25afa954 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java @@ -18,16 +18,14 @@ import io.modelcontextprotocol.modern.McpSchema.SubscriptionFilter; import io.modelcontextprotocol.modern.McpSchema.SubscriptionsAcknowledgedParams; import io.modelcontextprotocol.modern.McpSchema; -import io.modelcontextprotocol.modern.server.feature.McpAsyncResource; import io.modelcontextprotocol.modern.server.feature.McpAsyncResourceRepository; import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; -import io.modelcontextprotocol.modern.server.feature.ResourcesPage; import io.modelcontextprotocol.modern.server.feature.ServerChange; import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncResourceRepository; import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import static io.modelcontextprotocol.modern.server.ModernTestFixtures.PERMISSIVE_VALIDATOR; @@ -205,22 +203,9 @@ void resourceSubscribeIsNotAdvertisedWithoutSubscriptions() { } private static McpServer resourcesServer(boolean supportsSubscribe, McpChangePublisher publisher) { - McpAsyncResourceRepository repository = new McpAsyncResourceRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ResourcesPage.of(List.of())); - } - - @Override - public Mono find(McpRequestContext ctx, String uri) { - return Mono.empty(); - } - - @Override - public boolean supportsSubscribe() { - return supportsSubscribe; - } - }; + McpAsyncResourceRepository repository = InMemoryAsyncResourceRepository.builder() + .supportsSubscribe(supportsSubscribe) + .build(); McpServer.Builder builder = McpServer.builder() .serverInfo(SERVER_INFO) .feature(ResourcesFeature.ofAsync(repository, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java index 5e65c50ae..1512ae14b 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/CompletionsFeatureTests.java @@ -27,6 +27,8 @@ import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.McpSyncResponse; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncPromptRepository; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncResourceRepository; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -69,56 +71,24 @@ class CompletionsFeatureTests { "shop".equals(context.get("database")) ? List.of("orders", "customers") : List.of())); }; - private final McpSyncPromptRepository prompts = new McpSyncPromptRepository() { - @Override - public PromptsPage list(McpRequestContext ctx, String cursor) { - return PromptsPage.of(List.of(CODE_REVIEW, PLAIN)); - } - - @Override - public McpSyncPrompt find(McpRequestContext ctx, String name) { - return switch (name) { - case "code_review" -> prompt(CODE_REVIEW).withCompletionHandler(languages); - case "plain" -> prompt(PLAIN); - default -> null; - }; - } - - @Override - public boolean supportsCompletion() { - return true; - } - }; + // Completion never reads the templates, so their reader is never called. + private static final McpSyncResource NOT_READ = McpSyncResource.of((ctx, request) -> { + throw new AssertionError("completion must not read the resource"); + }); - private final McpSyncResourceRepository resources = new McpSyncResourceRepository() { - @Override - public ResourcesPage list(McpRequestContext ctx, String cursor) { - return ResourcesPage.of(List.of()); - } - - @Override - public ResourceTemplatesPage listTemplates(McpRequestContext ctx, String cursor) { - return ResourceTemplatesPage.of(List.of(TABLE, FILE)); - } - - @Override - public McpSyncResource find(McpRequestContext ctx, String uri) { - return null; - } - - @Override - public McpSyncResourceTemplate findTemplate(McpRequestContext ctx, String uriTemplate) { - if (TABLE.uriTemplate().equals(uriTemplate)) { - return McpSyncResourceTemplate.of(TABLE).withCompletionHandler(tables); - } - return FILE.uriTemplate().equals(uriTemplate) ? McpSyncResourceTemplate.of(FILE) : null; - } + private final McpSyncResourceRepository resources = InMemorySyncResourceRepository.builder() + .template(McpSyncResourceTemplate.of(TABLE).withCompletionHandler(this.tables), NOT_READ) + .template(McpSyncResourceTemplate.of(FILE), NOT_READ) + .supportsCompletion(true) + .build(); - @Override - public boolean supportsCompletion() { - return true; - } - }; + private McpSyncPromptRepository prompts(boolean supportsCompletion) { + return InMemorySyncPromptRepository.builder() + .prompt(prompt(CODE_REVIEW).withCompletionHandler(this.languages)) + .prompt(prompt(PLAIN)) + .supportsCompletion(supportsCompletion) + .build(); + } private static McpSyncPrompt prompt(Prompt definition) { return McpSyncPrompt.of(definition, @@ -130,7 +100,7 @@ private static McpServer server(McpFeature... features) { } private McpServer server() { - return server(PromptsFeature.ofSync(this.prompts), ResourcesFeature.ofSync(this.resources)); + return server(PromptsFeature.ofSync(prompts(true)), ResourcesFeature.ofSync(this.resources)); } private static JSONRPCResponse complete(McpServer server, Map ref, String argument, String value, @@ -227,7 +197,7 @@ void variableTheTemplateDoesNotDeclareIsInvalidParams() { @Test void refTypeNoFeatureCompletesIsInvalidParams() { - McpServer server = server(PromptsFeature.ofSync(this.prompts)); + McpServer server = server(PromptsFeature.ofSync(prompts(true))); JSONRPCResponse response = complete(server, resource(TABLE.uriTemplate()), "table", "", null); @@ -237,19 +207,7 @@ void refTypeNoFeatureCompletesIsInvalidParams() { @Test void completionIsNotServedWhenNoRepositorySupportsIt() { - McpSyncPromptRepository withoutCompletion = new McpSyncPromptRepository() { - @Override - public PromptsPage list(McpRequestContext ctx, String cursor) { - return prompts.list(ctx, cursor); - } - - @Override - public McpSyncPrompt find(McpRequestContext ctx, String name) { - return prompts.find(ctx, name); - } - }; - - JSONRPCResponse response = complete(server(PromptsFeature.ofSync(withoutCompletion)), prompt("code_review"), + JSONRPCResponse response = complete(server(PromptsFeature.ofSync(prompts(false))), prompt("code_review"), "language", "ja", null); assertThat(response.error().code()).isEqualTo(ErrorCodes.METHOD_NOT_FOUND); @@ -274,7 +232,8 @@ public Set completionRefTypes() { } }; - assertThatIllegalStateException().isThrownBy(() -> server(PromptsFeature.ofSync(this.prompts), promptCompleter)) + assertThatIllegalStateException() + .isThrownBy(() -> server(PromptsFeature.ofSync(prompts(true)), promptCompleter)) .withMessageContaining("ref/prompt"); } @@ -295,23 +254,10 @@ void missingArgumentIsRejectedAsInvalidParams() { } private static McpServer serverCompleting(CompleteResult.Completion completion) { - McpSyncPromptRepository prompts = new McpSyncPromptRepository() { - @Override - public PromptsPage list(McpRequestContext ctx, String cursor) { - return PromptsPage.of(List.of(PLAIN)); - } - - @Override - public McpSyncPrompt find(McpRequestContext ctx, String name) { - return prompt(PLAIN).withCompletionHandler((c, request) -> CompleteResult.of(completion)); - } - - @Override - public boolean supportsCompletion() { - return true; - } - }; - return server(PromptsFeature.ofSync(prompts)); + return server(PromptsFeature.ofSync(InMemorySyncPromptRepository.builder() + .prompt(prompt(PLAIN).withCompletionHandler((c, request) -> CompleteResult.of(completion))) + .supportsCompletion(true) + .build())); } private static List values(int count) { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java index 9530ae017..124f5ffe0 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java @@ -18,8 +18,8 @@ import io.modelcontextprotocol.modern.McpSchema.Prompt; import io.modelcontextprotocol.modern.McpSchema.PromptArgument; import io.modelcontextprotocol.modern.server.McpAsyncResponse; -import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncPromptRepository; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -37,21 +37,10 @@ class PromptsFeatureTests { private static final McpServer SERVER = McpServer.builder() .serverInfo(SERVER_INFO) - .feature(PromptsFeature.ofAsync(new McpAsyncPromptRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(PromptsPage.of(List.of(GREETING))); - } - - @Override - public Mono find(McpRequestContext ctx, String name) { - return GREETING.name().equals(name) - ? Mono.just(McpAsyncPrompt.of(GREETING, - (c, request) -> Mono - .just(McpAsyncResponse.result(GetPromptResult.builder(List.of()).build())))) - : Mono.empty(); - } - }, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) + .feature(PromptsFeature.ofAsync(InMemoryAsyncPromptRepository.builder() + .prompt(McpAsyncPrompt.of(GREETING, + (c, request) -> Mono.just(McpAsyncResponse.result(GetPromptResult.builder(List.of()).build())))) + .build(), McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) .build(); private static JSONRPCResponse get(String name, Map arguments) { @@ -81,21 +70,12 @@ void missingRequiredCapabilityIsRejectedWithoutRendering() { Prompt sampled = Prompt.builder("sampled").build(); McpServer server = McpServer.builder() .serverInfo(SERVER_INFO) - .feature(PromptsFeature.ofAsync(new McpAsyncPromptRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(PromptsPage.of(List.of(sampled))); - } - - @Override - public Mono find(McpRequestContext ctx, String name) { - return Mono.just(McpAsyncPrompt.of(sampled, ClientCapabilities.builder().sampling().build(), - (c, request) -> { - called.set(true); - return Mono.just(McpAsyncResponse.result(GetPromptResult.builder(List.of()).build())); - })); - } - }, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) + .feature(PromptsFeature.ofAsync(InMemoryAsyncPromptRepository.builder() + .prompt(McpAsyncPrompt.of(sampled, ClientCapabilities.builder().sampling().build(), (c, request) -> { + called.set(true); + return Mono.just(McpAsyncResponse.result(GetPromptResult.builder(List.of()).build())); + })) + .build(), McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) .build(); JSONRPCRequest request = new JSONRPCRequest("prompts/get", 1, Map.of("_meta", meta(), "name", "sampled")); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java index 4a9275d8b..93665fcc8 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureParamHeadersTests.java @@ -19,17 +19,15 @@ import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; import io.modelcontextprotocol.modern.McpSchema.CacheScope; -import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; -import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; import io.modelcontextprotocol.modern.McpSchema.CallToolResult; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.TextContent; import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.McpAsyncResponse; import io.modelcontextprotocol.modern.server.McpParamHeaders; -import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.ModernTestFixtures; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncToolRepository; import io.modelcontextprotocol.util.ToolsUtils; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -622,23 +620,13 @@ private McpServer server(String inputSchema, JsonSchemaValidator validator) { } private McpServer server(Tool tool, JsonSchemaValidator validator) { - McpAsyncToolRepository repository = new McpAsyncToolRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ToolsPage.of(List.of(tool))); - } - - @Override - public Mono find(McpRequestContext ctx, String name) { - return Mono.just(McpAsyncTool.of(tool, this::call)); - } - - public Mono> call(McpRequestContext ctx, CallToolRequest request) { - ToolsFeatureParamHeadersTests.this.called.set(true); + McpAsyncToolRepository repository = InMemoryAsyncToolRepository.builder() + .tool(McpAsyncTool.of(tool, (ctx, request) -> { + this.called.set(true); return Mono.just(McpAsyncResponse .result(CallToolResult.builder().addContent(TextContent.builder("ok").build()).build())); - } - }; + })) + .build(); return McpServer.builder() .serverInfo(SERVER_INFO) .feature(ToolsFeature.ofAsync(repository, McpJsonDefaults.getMapper(), validator, 0L, CacheScope.PRIVATE)) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java index 126d53ed0..e73dbe0f3 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java @@ -36,6 +36,8 @@ import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.McpSyncResponse; import io.modelcontextprotocol.modern.server.ModernTestFixtures; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncToolRepository; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncToolRepository; import io.modelcontextprotocol.util.ToolsUtils; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -107,17 +109,9 @@ private static JsonSchemaValidator rejecting(Map rejected) { private static McpServer structuredServer(JsonSchemaValidator validator, McpAsyncResponse response) { - McpAsyncToolRepository repo = new McpAsyncToolRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ToolsPage.of(List.of(STRUCTURED_TOOL))); - } - - @Override - public Mono find(McpRequestContext ctx, String name) { - return Mono.just(McpAsyncTool.of(STRUCTURED_TOOL, (c, request) -> Mono.just(response))); - } - }; + McpAsyncToolRepository repo = InMemoryAsyncToolRepository.builder() + .tool(McpAsyncTool.of(STRUCTURED_TOOL, (c, request) -> Mono.just(response))) + .build(); return baseBuilder() .feature(ToolsFeature.ofAsync(repo, McpJsonDefaults.getMapper(), validator, 0L, CacheScope.PRIVATE)) .build(); @@ -191,20 +185,10 @@ void structuredContentGetsATextFallback() { .build(); private static McpAsyncToolRepository requiring(ClientCapabilities required, AtomicBoolean called) { - return new McpAsyncToolRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ToolsPage.of(List.of(ECHO_TOOL))); - } - - @Override - public Mono find(McpRequestContext ctx, String name) { - return Mono.just(McpAsyncTool.of(ECHO_TOOL, required, (c, request) -> { - called.set(true); - return Mono.just(McpAsyncResponse.result(greeting("client"))); - })); - } - }; + return InMemoryAsyncToolRepository.builder().tool(McpAsyncTool.of(ECHO_TOOL, required, (c, request) -> { + called.set(true); + return Mono.just(McpAsyncResponse.result(greeting("client"))); + })).build(); } private static JSONRPCRequest callEchoDeclaring(Map clientCapabilities) { @@ -262,21 +246,13 @@ void onlyTheMissingCapabilitiesAreReported() { @Test void syncToolRequirementIsCheckedBeforeTheStreamStarts() { AtomicBoolean called = new AtomicBoolean(); - McpSyncToolRepository repo = new McpSyncToolRepository() { - @Override - public ToolsPage list(McpRequestContext ctx, String cursor) { - return ToolsPage.of(List.of(ECHO_TOOL)); - } - - @Override - public McpSyncTool find(McpRequestContext ctx, String name) { - return McpSyncTool.of(ECHO_TOOL, ClientCapabilities.builder().sampling().build(), - (c, request) -> McpSyncResponse.streaming(notifier -> { - called.set(true); - return greeting("client"); - })); - } - }; + McpSyncToolRepository repo = InMemorySyncToolRepository.builder() + .tool(McpSyncTool.of(ECHO_TOOL, ClientCapabilities.builder().sampling().build(), + (c, request) -> McpSyncResponse.streaming(notifier -> { + called.set(true); + return greeting("client"); + }))) + .build(); McpServer server = baseBuilder().feature(tools(repo)).build(); McpTransportResponse response = server.handle(McpTransportContext.EMPTY, callEchoDeclaring(Map.of())).block(); @@ -317,20 +293,10 @@ private static String greetingText(JSONRPCMessage message) { private static McpSyncToolRepository singleGreetingRepo(Function principal, AtomicReference handlerThread) { - return new McpSyncToolRepository() { - @Override - public ToolsPage list(McpRequestContext ctx, String cursor) { - return ToolsPage.of(List.of(ECHO_TOOL)); - } - - @Override - public McpSyncTool find(McpRequestContext ctx, String name) { - return McpSyncTool.of(ECHO_TOOL, (c, request) -> { - handlerThread.set(Thread.currentThread().getName()); - return McpSyncResponse.result(greeting(principal.apply(c))); - }); - } - }; + return InMemorySyncToolRepository.builder().tool(McpSyncTool.of(ECHO_TOOL, (c, request) -> { + handlerThread.set(Thread.currentThread().getName()); + return McpSyncResponse.result(greeting(principal.apply(c))); + })).build(); } // Records how many messages the consumer had already received when the handler's @@ -338,22 +304,14 @@ public McpSyncTool find(McpRequestContext ctx, String name) { private static McpSyncToolRepository streamingGreetingRepo(Function principal, AtomicReference handlerThread, List delivered, AtomicInteger deliveredWhenProgressReturned) { - return new McpSyncToolRepository() { - @Override - public ToolsPage list(McpRequestContext ctx, String cursor) { - return ToolsPage.of(List.of(ECHO_TOOL)); - } - - @Override - public McpSyncTool find(McpRequestContext ctx, String name) { - return McpSyncTool.of(ECHO_TOOL, (c, request) -> McpSyncResponse.streaming(notifier -> { - handlerThread.set(Thread.currentThread().getName()); - notifier.progress(1.0, 1.0, "greeting"); - deliveredWhenProgressReturned.set(delivered.size()); - return greeting(principal.apply(c)); - })); - } - }; + return InMemorySyncToolRepository.builder() + .tool(McpSyncTool.of(ECHO_TOOL, (c, request) -> McpSyncResponse.streaming(notifier -> { + handlerThread.set(Thread.currentThread().getName()); + notifier.progress(1.0, 1.0, "greeting"); + deliveredWhenProgressReturned.set(delivered.size()); + return greeting(principal.apply(c)); + }))) + .build(); } private static JSONRPCResponse callSingle(Mono invocation) { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/repositories/InMemoryAsyncRepositoriesTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/repositories/InMemoryAsyncRepositoriesTests.java new file mode 100644 index 000000000..b67ace912 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/repositories/InMemoryAsyncRepositoriesTests.java @@ -0,0 +1,330 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repositories; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.GetPromptResult; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.PromptArgument; +import io.modelcontextprotocol.modern.McpSchema.PromptMessage; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.modern.McpSchema.Role; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.TextResourceContents; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpFeature; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.feature.McpAsyncPrompt; +import io.modelcontextprotocol.modern.server.feature.McpAsyncResource; +import io.modelcontextprotocol.modern.server.feature.McpAsyncResourceTemplate; +import io.modelcontextprotocol.modern.server.feature.McpAsyncTool; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.PromptsFeature; +import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncPromptRepository; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.repository.InMemoryAsyncToolRepository; +import io.modelcontextprotocol.util.ToolsUtils; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Drives the async in-memory repositories through a real {@link McpServer}. They mirror + * the sync repositories, whose edge cases {@link InMemorySyncRepositoriesTests} covers in + * depth; these tests cover the main behaviour through the async API, plus what it adds: + * results delivered as {@code Mono}s, read lazily, with cursor errors as error signals. + * + * @author Christian Tzolov + */ +class InMemoryAsyncRepositoriesTests { + + private static JSONRPCResponse call(McpServer server, String method, Object... params) { + Map body = new HashMap<>(); + body.put("_meta", meta()); + for (int i = 0; i < params.length; i += 2) { + body.put((String) params[i], params[i + 1]); + } + return respond(server, new JSONRPCRequest(method, 1, body)).block(); + } + + @SuppressWarnings("unchecked") + private static Map result(JSONRPCResponse response) { + assertThat(response.error()).isNull(); + return (Map) response.result(); + } + + @SuppressWarnings("unchecked") + private static List names(JSONRPCResponse response, String listKey) { + return ((List>) result(response).get(listKey)).stream() + .map(entry -> entry.get("name")) + .toList(); + } + + private static McpServer server(McpFeature feature) { + return McpServer.builder().serverInfo(SERVER_INFO).feature(feature).build(); + } + + private static McpAsyncTool tool(String name) { + return McpAsyncTool.of(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build(), + (ctx, request) -> Mono.just(McpAsyncResponse.result(CallToolResult.builder() + .addContent(TextContent.builder(name + ":" + request.arguments().get("text")).build()) + .build()))); + } + + private static McpAsyncPrompt prompt(String name) { + return McpAsyncPrompt.of( + Prompt.builder(name).arguments(List.of(PromptArgument.builder("language").build())).build(), + (ctx, request) -> Mono.just(McpAsyncResponse.result(GetPromptResult + .builder(List.of(PromptMessage.builder(Role.USER, TextContent.builder("hello").build()).build())) + .build()))); + } + + @Test + @SuppressWarnings("unchecked") + void toolRepositoryListsAndCallsTools() { + McpServer server = server(ToolsFeature + .ofAsync(InMemoryAsyncToolRepository.builder().tools(List.of(tool("b"), tool("a"))).build())); + + assertThat(names(call(server, "tools/list"), "tools")).containsExactly("a", "b"); + Map called = result(call(server, "tools/call", "name", "a", "arguments", Map.of("text", "hi"))); + assertThat(((List>) called.get("content")).get(0).get("text")).isEqualTo("a:hi"); + assertThat(call(server, "tools/call", "name", "nope").error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void toolRepositoryPagesByName() { + McpServer server = server(ToolsFeature.ofAsync(InMemoryAsyncToolRepository.builder() + .tools(List.of(tool("c"), tool("a"), tool("b"))) + .pageSize(2) + .build())); + + JSONRPCResponse first = call(server, "tools/list"); + assertThat(names(first, "tools")).containsExactly("a", "b"); + JSONRPCResponse second = call(server, "tools/list", "cursor", result(first).get("nextCursor")); + assertThat(names(second, "tools")).containsExactly("c"); + assertThat(result(second).get("nextCursor")).isNull(); + } + + @Test + void unknownCursorIsAnErrorSignal() { + InMemoryAsyncToolRepository repository = InMemoryAsyncToolRepository.builder().tool(tool("a")).build(); + + // Not thrown from list(): an async repository reports failures through the Mono. + Mono page = repository.list(null, "stale"); + + StepVerifier.create(page).expectError(McpException.class).verify(); + assertThat(call(server(ToolsFeature.ofAsync(repository)), "tools/list", "cursor", "stale").error().code()) + .isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void listAndFindReadTheCatalogWhenSubscribed() { + InMemoryAsyncToolRepository repository = InMemoryAsyncToolRepository.builder().build(); + Mono list = repository.list(null, null); + Mono find = repository.find(null, "a"); + + repository.add(tool("a")); + + StepVerifier.create(list) + .assertNext(page -> assertThat(page.tools()).extracting(Tool::name).containsExactly("a")) + .verifyComplete(); + StepVerifier.create(find).expectNextCount(1).verifyComplete(); + StepVerifier.create(repository.find(null, "nope")).verifyComplete(); + } + + @Test + void toolRepositoryBroadcastsChangesAndRejectsDuplicates() { + McpChangeFeed changes = new McpChangeFeed(); + InMemoryAsyncToolRepository repository = InMemoryAsyncToolRepository.builder() + .changeBroadcaster(changes) + .build(); + + StepVerifier.create(changes.changes().take(2)) + .then(() -> repository.add(tool("a"))) + .expectNext(new ServerChange.ToolsListChanged()) + .then(() -> repository.remove("a")) + .expectNext(new ServerChange.ToolsListChanged()) + .verifyComplete(); + + repository.add(tool("a")); + assertThatThrownBy(() -> repository.add(tool("a"))).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("a"); + } + + @Test + void promptRepositoryListsAndGetsPrompts() { + McpServer server = server(PromptsFeature.ofAsync( + InMemoryAsyncPromptRepository.builder().prompts(List.of(prompt("review"), prompt("greet"))).build())); + + assertThat(names(call(server, "prompts/list"), "prompts")).containsExactly("greet", "review"); + assertThat((List) result(call(server, "prompts/get", "name", "greet")).get("messages")).hasSize(1); + assertThat(call(server, "prompts/get", "name", "nope").error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void promptRepositoryBroadcastsChanges() { + McpChangeFeed changes = new McpChangeFeed(); + InMemoryAsyncPromptRepository repository = InMemoryAsyncPromptRepository.builder() + .changeBroadcaster(changes) + .build(); + + StepVerifier.create(changes.changes().take(2)) + .then(() -> repository.add(prompt("greet"))) + .expectNext(new ServerChange.PromptsListChanged()) + .then(() -> repository.remove("greet")) + .expectNext(new ServerChange.PromptsListChanged()) + .verifyComplete(); + } + + @Test + @SuppressWarnings("unchecked") + void promptCompletionIsServedWhenSupported() { + InMemoryAsyncPromptRepository repository = InMemoryAsyncPromptRepository.builder() + .prompt(prompt("greet").withCompletionHandler((ctx, request) -> Mono + .just(CompleteResult.of(new CompleteResult.Completion(List.of("java", "kotlin")))))) + .supportsCompletion(true) + .build(); + McpServer server = server(PromptsFeature.ofAsync(repository)); + + JSONRPCResponse response = call(server, "completion/complete", "ref", + Map.of("type", "ref/prompt", "name", "greet"), "argument", Map.of("name", "language", "value", "")); + + Map completion = (Map) result(response).get("completion"); + assertThat((List) completion.get("values")).containsExactly("java", "kotlin"); + } + + @Test + void promptCompletionIsNotServedByDefault() { + McpServer server = server(PromptsFeature.ofAsync(InMemoryAsyncPromptRepository.builder() + .prompt(prompt("greet").withCompletionHandler( + (ctx, request) -> Mono.just(CompleteResult.of(new CompleteResult.Completion(List.of("java")))))) + .build())); + + JSONRPCResponse response = call(server, "completion/complete", "ref", + Map.of("type", "ref/prompt", "name", "greet"), "argument", Map.of("name", "language", "value", "")); + + assertThat(response.error()).isNotNull(); + } + + private static McpAsyncResource reader(String prefix) { + return McpAsyncResource.of((ctx, + request) -> Mono.just(McpAsyncResponse.result(ReadResourceResult + .builder(List.of(TextResourceContents.builder(request.uri(), prefix + request.uri()).build())) + .build()))); + } + + private static McpAsyncResourceTemplate template(String uriTemplate) { + return McpAsyncResourceTemplate.of(ResourceTemplate.builder(uriTemplate, uriTemplate).build()); + } + + @SuppressWarnings("unchecked") + private static String readText(McpServer server, String uri) { + Map read = result(call(server, "resources/read", "uri", uri)); + return (String) ((List>) read.get("contents")).get(0).get("text"); + } + + @Test + @SuppressWarnings("unchecked") + void resourceRepositoryListsAndReadsStaticAndTemplatedUris() { + McpServer server = server(ResourcesFeature.ofAsync(InMemoryAsyncResourceRepository.builder() + .resource(Resource.builder("test://items/special", "special").build(), reader("static:")) + .template(template("test://items/{id}"), reader("item:")) + .build())); + + List> resources = (List>) result(call(server, "resources/list")) + .get("resources"); + assertThat(resources).extracting(resource -> resource.get("uri")).containsExactly("test://items/special"); + List> templates = (List>) result( + call(server, "resources/templates/list")) + .get("resourceTemplates"); + assertThat(templates).extracting(template -> template.get("uriTemplate")).containsExactly("test://items/{id}"); + // An exact resource takes precedence over a template matching the same URI. + assertThat(readText(server, "test://items/special")).isEqualTo("static:test://items/special"); + assertThat(readText(server, "test://items/42")).isEqualTo("item:test://items/42"); + assertThat(call(server, "resources/read", "uri", "test://nope").error().code()) + .isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void resourceRepositoryReadsTheCatalogWhenSubscribed() { + InMemoryAsyncResourceRepository repository = InMemoryAsyncResourceRepository.builder().build(); + Mono find = repository.find(null, "test://items/42"); + Mono findTemplate = repository.findTemplate(null, "test://items/{id}"); + + repository.addTemplate(template("test://items/{id}"), reader("")); + + StepVerifier.create(find).expectNextCount(1).verifyComplete(); + StepVerifier.create(findTemplate).expectNextCount(1).verifyComplete(); + StepVerifier.create(repository.find(null, "test://nope")).verifyComplete(); + StepVerifier.create(repository.findTemplate(null, "test://items/42")).verifyComplete(); + StepVerifier.create(repository.listTemplates(null, "stale")).expectError(McpException.class).verify(); + } + + @Test + void resourceRepositoryBroadcastsChanges() { + McpChangeFeed changes = new McpChangeFeed(); + InMemoryAsyncResourceRepository repository = InMemoryAsyncResourceRepository.builder() + .changeBroadcaster(changes) + .supportsSubscribe(true) + .build(); + + StepVerifier.create(changes.changes().take(5)) + .then(() -> repository.addResource(Resource.builder("test://a", "a").build(), reader(""))) + .expectNext(new ServerChange.ResourcesListChanged()) + .then(() -> repository.addTemplate(template("test://items/{id}"), reader(""))) + .expectNext(new ServerChange.ResourcesListChanged()) + .then(() -> repository.resourceUpdated("test://a")) + .expectNext(new ServerChange.ResourceUpdated("test://a")) + .then(() -> repository.removeResource("test://a")) + .expectNext(new ServerChange.ResourcesListChanged()) + .then(() -> repository.removeTemplate("test://items/{id}")) + .expectNext(new ServerChange.ResourcesListChanged()) + .verifyComplete(); + + assertThat(repository.supportsSubscribe()).isTrue(); + assertThat(repository.removeResource("test://a")).isFalse(); + } + + @Test + @SuppressWarnings("unchecked") + void templateCompletionIsServedWhenSupported() { + McpServer server = server(ResourcesFeature.ofAsync(InMemoryAsyncResourceRepository.builder() + .template(template("test://items/{id}").withCompletionHandler( + (ctx, request) -> Mono.just(CompleteResult.of(new CompleteResult.Completion(List.of("1", "2"))))), + reader("")) + .supportsCompletion(true) + .build())); + + JSONRPCResponse response = call(server, "completion/complete", "ref", + Map.of("type", "ref/resource", "uri", "test://items/{id}"), "argument", + Map.of("name", "id", "value", "")); + + Map completion = (Map) result(response).get("completion"); + assertThat((List) completion.get("values")).containsExactly("1", "2"); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/repositories/InMemorySyncRepositoriesTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/repositories/InMemorySyncRepositoriesTests.java new file mode 100644 index 000000000..8fe0d4390 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/repositories/InMemorySyncRepositoriesTests.java @@ -0,0 +1,407 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature.repositories; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; +import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; +import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.CompleteResult; +import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; +import io.modelcontextprotocol.modern.McpSchema.GetPromptResult; +import io.modelcontextprotocol.modern.McpSchema.Prompt; +import io.modelcontextprotocol.modern.McpSchema.PromptArgument; +import io.modelcontextprotocol.modern.McpSchema.PromptMessage; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.modern.McpSchema.Resource; +import io.modelcontextprotocol.modern.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.modern.McpSchema.Role; +import io.modelcontextprotocol.modern.McpSchema.TextContent; +import io.modelcontextprotocol.modern.McpSchema.TextResourceContents; +import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpFeature; +import io.modelcontextprotocol.modern.server.McpServer; +import io.modelcontextprotocol.modern.server.McpSyncResponse; +import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; +import io.modelcontextprotocol.modern.server.feature.McpSyncPrompt; +import io.modelcontextprotocol.modern.server.feature.McpSyncResource; +import io.modelcontextprotocol.modern.server.feature.McpSyncResourceTemplate; +import io.modelcontextprotocol.modern.server.feature.McpSyncTool; +import io.modelcontextprotocol.modern.server.feature.PromptsFeature; +import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; +import io.modelcontextprotocol.modern.server.feature.ServerChange; +import io.modelcontextprotocol.modern.server.feature.ToolsFeature; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncPromptRepository; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncResourceRepository; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncToolRepository; +import io.modelcontextprotocol.util.ToolsUtils; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.SERVER_INFO; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.meta; +import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Drives the in-memory repositories through a real {@link McpServer}, so that the + * features' handling of what the repositories return (unknown names, cursors, required + * capabilities, completion) is covered too. + * + * @author Christian Tzolov + */ +class InMemorySyncRepositoriesTests { + + private static JSONRPCResponse call(McpServer server, String method, Object... params) { + Map body = new HashMap<>(); + body.put("_meta", meta()); + for (int i = 0; i < params.length; i += 2) { + body.put((String) params[i], params[i + 1]); + } + return respond(server, new JSONRPCRequest(method, 1, body)).block(); + } + + @SuppressWarnings("unchecked") + private static Map result(JSONRPCResponse response) { + assertThat(response.error()).isNull(); + return (Map) response.result(); + } + + @SuppressWarnings("unchecked") + private static List names(JSONRPCResponse response, String listKey, String nameKey) { + return ((List>) result(response).get(listKey)).stream() + .map(entry -> entry.get(nameKey)) + .toList(); + } + + @SuppressWarnings("unchecked") + private static List completionValues(JSONRPCResponse response) { + return (List) ((Map) result(response).get("completion")).get("values"); + } + + @SuppressWarnings("unchecked") + private static String firstText(JSONRPCResponse response, String listKey) { + return (String) ((List>) result(response).get(listKey)).get(0).get("text"); + } + + private static McpServer server(McpFeature feature) { + return McpServer.builder().serverInfo(SERVER_INFO).feature(feature).build(); + } + + private static McpSyncTool tool(String name) { + return McpSyncTool.of(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build(), + (ctx, request) -> McpSyncResponse.result(CallToolResult.builder() + .addContent(TextContent.builder(name + ":" + request.arguments().get("text")).build()) + .build())); + } + + private static McpSyncPrompt prompt(String name) { + return McpSyncPrompt.of( + Prompt.builder(name).arguments(List.of(PromptArgument.builder("language").build())).build(), + (ctx, request) -> McpSyncResponse.result(GetPromptResult + .builder(List.of(PromptMessage.builder(Role.USER, TextContent.builder("hello").build()).build())) + .build())); + } + + private static McpSyncResource reader(String prefix) { + return McpSyncResource.of((ctx, + request) -> McpSyncResponse.result(ReadResourceResult + .builder(List.of(TextResourceContents.builder(request.uri(), prefix + request.uri()).build())) + .build())); + } + + private static McpSyncResourceTemplate template(String uriTemplate) { + return McpSyncResourceTemplate.of(ResourceTemplate.builder(uriTemplate, uriTemplate).build()); + } + + // Tools + + @Test + void toolRepositoryListsAndCallsTools() { + McpServer server = server(ToolsFeature.ofSync(InMemorySyncToolRepository.builder().tool(tool("echo")).build())); + + assertThat(names(call(server, "tools/list"), "tools", "name")).containsExactly("echo"); + assertThat(firstText(call(server, "tools/call", "name", "echo", "arguments", Map.of("text", "hi")), "content")) + .isEqualTo("echo:hi"); + assertThat(call(server, "tools/call", "name", "nope").error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void toolRequiringUndeclaredCapabilityIsRejectedBeforeTheHandlerRuns() { + McpSyncTool sampling = McpSyncTool.of(Tool.builder("sample", ToolsUtils.EMPTY_JSON_SCHEMA).build(), + ClientCapabilities.builder().sampling().build(), (ctx, request) -> { + throw new AssertionError("handler must not run"); + }); + McpServer server = server(ToolsFeature.ofSync(InMemorySyncToolRepository.builder().tool(sampling).build())); + + assertThat(call(server, "tools/call", "name", "sample").error().code()) + .isEqualTo(ErrorCodes.MISSING_REQUIRED_CLIENT_CAPABILITY); + } + + @Test + void toolRepositoryPagesByName() { + InMemorySyncToolRepository repository = InMemorySyncToolRepository.builder() + .tools(List.of(tool("c"), tool("a"), tool("b"))) + .pageSize(2) + .build(); + McpServer server = server(ToolsFeature.ofSync(repository)); + + JSONRPCResponse first = call(server, "tools/list"); + assertThat(names(first, "tools", "name")).containsExactly("a", "b"); + Object cursor = result(first).get("nextCursor"); + assertThat(cursor).isNotNull(); + + JSONRPCResponse second = call(server, "tools/list", "cursor", cursor); + assertThat(names(second, "tools", "name")).containsExactly("c"); + assertThat(result(second).get("nextCursor")).isNull(); + } + + @Test + void cursorStaysValidWhenTheLastListedEntryIsRemoved() { + InMemorySyncToolRepository repository = InMemorySyncToolRepository.builder() + .tools(List.of(tool("a"), tool("b"), tool("c"))) + .pageSize(2) + .build(); + McpServer server = server(ToolsFeature.ofSync(repository)); + Object cursor = result(call(server, "tools/list")).get("nextCursor"); + + repository.remove("b"); + + assertThat(names(call(server, "tools/list", "cursor", cursor), "tools", "name")).containsExactly("c"); + } + + @Test + void unknownCursorIsInvalidParams() { + McpServer unpaged = server( + ToolsFeature.ofSync(InMemorySyncToolRepository.builder().tool(tool("echo")).build())); + McpServer paged = server( + ToolsFeature.ofSync(InMemorySyncToolRepository.builder().tool(tool("echo")).pageSize(1).build())); + + for (McpServer server : List.of(unpaged, paged)) { + JSONRPCResponse response = call(server, "tools/list", "cursor", "stale"); + assertThat(response.error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + assertThat(response.error().message()).contains("stale"); + } + } + + @Test + void toolRepositoryBroadcastsChanges() { + McpChangeFeed changes = new McpChangeFeed(); + InMemorySyncToolRepository repository = InMemorySyncToolRepository.builder().changeBroadcaster(changes).build(); + + StepVerifier.create(changes.changes().take(2)) + .then(() -> repository.add(tool("echo"))) + .expectNext(new ServerChange.ToolsListChanged()) + .then(() -> repository.remove("echo")) + .expectNext(new ServerChange.ToolsListChanged()) + .verifyComplete(); + + assertThat(repository.list(null, null).tools()).isEmpty(); + assertThat(repository.remove("echo")).isFalse(); + } + + @Test + void toolRepositoryRejectsDuplicates() { + InMemorySyncToolRepository repository = InMemorySyncToolRepository.builder().tool(tool("echo")).build(); + + assertThatThrownBy(() -> repository.add(tool("echo"))).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("echo"); + assertThatThrownBy(() -> InMemorySyncToolRepository.builder().tool(tool("echo")).tool(tool("echo")).build()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void pageSizeMustBePositive() { + assertThatThrownBy(() -> InMemorySyncToolRepository.builder().pageSize(0)) + .isInstanceOf(IllegalArgumentException.class); + } + + // Prompts + + @Test + void promptRepositoryListsAndGetsPrompts() { + InMemorySyncPromptRepository repository = InMemorySyncPromptRepository.builder() + .prompts(List.of(prompt("review"), prompt("greet"))) + .build(); + McpServer server = server(PromptsFeature.ofSync(repository)); + + assertThat(names(call(server, "prompts/list"), "prompts", "name")).containsExactly("greet", "review"); + assertThat((List) result(call(server, "prompts/get", "name", "greet")).get("messages")).hasSize(1); + assertThat(call(server, "prompts/get", "name", "nope").error().code()).isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void promptRepositoryBroadcastsChanges() { + McpChangeFeed changes = new McpChangeFeed(); + InMemorySyncPromptRepository repository = InMemorySyncPromptRepository.builder() + .changeBroadcaster(changes) + .build(); + + StepVerifier.create(changes.changes().take(2)) + .then(() -> repository.add(prompt("greet"))) + .expectNext(new ServerChange.PromptsListChanged()) + .then(() -> repository.remove("greet")) + .expectNext(new ServerChange.PromptsListChanged()) + .verifyComplete(); + } + + @Test + void promptCompletionIsServedWhenSupported() { + InMemorySyncPromptRepository repository = InMemorySyncPromptRepository.builder() + .prompt(prompt("greet").withCompletionHandler( + (ctx, request) -> CompleteResult.of(new CompleteResult.Completion(List.of("java", "kotlin"))))) + .supportsCompletion(true) + .build(); + McpServer server = server(PromptsFeature.ofSync(repository)); + + JSONRPCResponse response = call(server, "completion/complete", "ref", + Map.of("type", "ref/prompt", "name", "greet"), "argument", Map.of("name", "language", "value", "")); + + assertThat(completionValues(response)).containsExactly("java", "kotlin"); + } + + @Test + void promptAddedAfterBuildIsCompleted() { + InMemorySyncPromptRepository repository = InMemorySyncPromptRepository.builder() + .supportsCompletion(true) + .build(); + McpServer server = server(PromptsFeature.ofSync(repository)); + + repository.add(prompt("greet").withCompletionHandler( + (ctx, request) -> CompleteResult.of(new CompleteResult.Completion(List.of("java"))))); + + JSONRPCResponse response = call(server, "completion/complete", "ref", + Map.of("type", "ref/prompt", "name", "greet"), "argument", Map.of("name", "language", "value", "")); + assertThat(completionValues(response)).containsExactly("java"); + } + + @Test + void promptCompletionIsNotServedByDefault() { + InMemorySyncPromptRepository repository = InMemorySyncPromptRepository.builder() + .prompt(prompt("greet").withCompletionHandler( + (ctx, request) -> CompleteResult.of(new CompleteResult.Completion(List.of("java"))))) + .build(); + McpServer server = server(PromptsFeature.ofSync(repository)); + + JSONRPCResponse response = call(server, "completion/complete", "ref", + Map.of("type", "ref/prompt", "name", "greet"), "argument", Map.of("name", "language", "value", "")); + + assertThat(response.error()).isNotNull(); + } + + // Resources + + @Test + void resourceRepositoryReadsStaticAndTemplatedUris() { + InMemorySyncResourceRepository repository = InMemorySyncResourceRepository.builder() + .resource(Resource.builder("test://items/special", "special").build(), reader("static:")) + .template(template("test://items/{id}"), reader("item:")) + .build(); + McpServer server = server(ResourcesFeature.ofSync(repository)); + + assertThat(names(call(server, "resources/list"), "resources", "uri")).containsExactly("test://items/special"); + assertThat(names(call(server, "resources/templates/list"), "resourceTemplates", "uriTemplate")) + .containsExactly("test://items/{id}"); + // An exact resource takes precedence over a template matching the same URI. + assertThat(firstText(call(server, "resources/read", "uri", "test://items/special"), "contents")) + .isEqualTo("static:test://items/special"); + assertThat(firstText(call(server, "resources/read", "uri", "test://items/42"), "contents")) + .isEqualTo("item:test://items/42"); + assertThat(call(server, "resources/read", "uri", "test://nope").error().code()) + .isEqualTo(ErrorCodes.INVALID_PARAMS); + } + + @Test + void resourceRepositoryRejectsDuplicates() { + InMemorySyncResourceRepository repository = InMemorySyncResourceRepository.builder() + .resource(Resource.builder("test://a", "a").build(), reader("")) + .template(template("test://items/{id}"), reader("")) + .build(); + + assertThatThrownBy(() -> repository.addResource(Resource.builder("test://a", "dup").build(), reader(""))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("test://a"); + assertThatThrownBy(() -> repository.addTemplate(template("test://items/{id}"), reader(""))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("test://items/{id}"); + } + + @Test + void resourceRepositoryBroadcastsChanges() { + McpChangeFeed changes = new McpChangeFeed(); + InMemorySyncResourceRepository repository = InMemorySyncResourceRepository.builder() + .changeBroadcaster(changes) + .supportsSubscribe(true) + .build(); + + StepVerifier.create(changes.changes().take(5)) + .then(() -> repository.addResource(Resource.builder("test://a", "a").build(), reader(""))) + .expectNext(new ServerChange.ResourcesListChanged()) + .then(() -> repository.addTemplate(template("test://items/{id}"), reader(""))) + .expectNext(new ServerChange.ResourcesListChanged()) + .then(() -> repository.resourceUpdated("test://a")) + .expectNext(new ServerChange.ResourceUpdated("test://a")) + .then(() -> repository.removeResource("test://a")) + .expectNext(new ServerChange.ResourcesListChanged()) + .then(() -> repository.removeTemplate("test://items/{id}")) + .expectNext(new ServerChange.ResourcesListChanged()) + .verifyComplete(); + + assertThat(repository.supportsSubscribe()).isTrue(); + assertThat(repository.list(null, null).resources()).isEmpty(); + assertThat(repository.listTemplates(null, null).resourceTemplates()).isEmpty(); + assertThat(repository.removeResource("test://a")).isFalse(); + assertThat(repository.removeTemplate("test://items/{id}")).isFalse(); + } + + @Test + void resourceRepositoryPagesResourcesAndTemplates() { + InMemorySyncResourceRepository repository = InMemorySyncResourceRepository.builder() + .resource(Resource.builder("test://b", "b").build(), reader("")) + .resource(Resource.builder("test://a", "a").build(), reader("")) + .template(template("test://y/{id}"), reader("")) + .template(template("test://x/{id}"), reader("")) + .pageSize(1) + .build(); + McpServer server = server(ResourcesFeature.ofSync(repository)); + + JSONRPCResponse resources = call(server, "resources/list"); + assertThat(names(resources, "resources", "uri")).containsExactly("test://a"); + assertThat(names(call(server, "resources/list", "cursor", result(resources).get("nextCursor")), "resources", + "uri")) + .containsExactly("test://b"); + + JSONRPCResponse templates = call(server, "resources/templates/list"); + assertThat(names(templates, "resourceTemplates", "uriTemplate")).containsExactly("test://x/{id}"); + assertThat(names(call(server, "resources/templates/list", "cursor", result(templates).get("nextCursor")), + "resourceTemplates", "uriTemplate")) + .containsExactly("test://y/{id}"); + } + + @Test + void templateCompletionIsServedWhenSupported() { + InMemorySyncResourceRepository repository = InMemorySyncResourceRepository.builder() + .template( + template("test://items/{id}").withCompletionHandler( + (ctx, request) -> CompleteResult.of(new CompleteResult.Completion(List.of("1", "2")))), + reader("")) + .supportsCompletion(true) + .build(); + McpServer server = server(ResourcesFeature.ofSync(repository)); + + JSONRPCResponse response = call(server, "completion/complete", "ref", + Map.of("type", "ref/resource", "uri", "test://items/{id}"), "argument", + Map.of("name", "id", "value", "")); + + assertThat(completionValues(response)).containsExactly("1", "2"); + assertThat(repository.findTemplate(null, "test://items/42")).isNull(); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportSlowClientIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportSlowClientIntegrationTests.java index 2887cb271..b642a0cb3 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportSlowClientIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportSlowClientIntegrationTests.java @@ -11,7 +11,6 @@ import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -22,22 +21,18 @@ import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.Implementation; import io.modelcontextprotocol.modern.McpSchema.MetaKeys; -import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpServer; -import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; +import io.modelcontextprotocol.modern.server.ModernTestFixtures; import io.modelcontextprotocol.modern.server.feature.McpChangeFeed; import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; import io.modelcontextprotocol.modern.server.feature.ServerChange; import io.modelcontextprotocol.modern.server.feature.ToolsFeature; -import io.modelcontextprotocol.modern.server.feature.ToolsPage; -import io.modelcontextprotocol.modern.server.feature.McpAsyncTool; import io.modelcontextprotocol.server.transport.TomcatTestUtil; import org.apache.catalina.LifecycleException; import org.apache.catalina.startup.Tomcat; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; import static org.assertj.core.api.Assertions.assertThat; @@ -65,17 +60,7 @@ static void startServer() { McpServer server = McpServer.builder() .serverInfo(Implementation.builder("slow-client-test-server", "1.0.0").build()) .jsonMapper(JSON_MAPPER) - .feature(ToolsFeature.ofAsync(new McpAsyncToolRepository() { - @Override - public Mono list(McpRequestContext ctx, String cursor) { - return Mono.just(ToolsPage.of(List.of())); - } - - @Override - public Mono find(McpRequestContext ctx, String name) { - return Mono.empty(); - } - })) + .feature(ToolsFeature.ofAsync(ModernTestFixtures.emptyTools())) .subscriptions(publisher) .build(); HttpServletMcpTransport transport = HttpServletMcpTransport.builder(server) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java index 39ffa5ebf..d0c47ed8a 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java @@ -44,7 +44,6 @@ import io.modelcontextprotocol.modern.McpSchema.TextContent; import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.McpParamHeaders; -import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.McpSyncResponse; @@ -52,10 +51,9 @@ import io.modelcontextprotocol.modern.server.feature.McpAsyncTool; import io.modelcontextprotocol.modern.server.feature.McpChangePublisher; import io.modelcontextprotocol.modern.server.feature.McpSyncTool; -import io.modelcontextprotocol.modern.server.feature.McpSyncToolRepository; import io.modelcontextprotocol.modern.server.feature.ServerChange; import io.modelcontextprotocol.modern.server.feature.ToolsFeature; -import io.modelcontextprotocol.modern.server.feature.ToolsPage; +import io.modelcontextprotocol.modern.server.feature.repository.InMemorySyncToolRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -582,19 +580,11 @@ void annotatedToolIsCalledWithoutParamHeaders() throws Exception { McpServer server = McpServer.builder() .serverInfo(SERVER_INFO) .jsonMapper(this.jsonMapper) - .feature(ToolsFeature.ofSync(new McpSyncToolRepository() { - @Override - public ToolsPage list(McpRequestContext ctx, String cursor) { - return ToolsPage.of(List.of(tool)); - } - - @Override - public McpSyncTool find(McpRequestContext ctx, String name) { - return McpSyncTool.of(tool, (callCtx, req) -> McpSyncResponse - .result(CallToolResult.builder().addContent(TextContent.builder("ok").build()).build())); - } - - }, this.jsonMapper, PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)) + .feature( + ToolsFeature.ofSync(InMemorySyncToolRepository.builder() + .tool(McpSyncTool.of(tool, (callCtx, req) -> McpSyncResponse + .result(CallToolResult.builder().addContent(TextContent.builder("ok").build()).build()))) + .build(), this.jsonMapper, PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)) .build(); start(server);