diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/CompletionsRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/CompletionsRepository.java new file mode 100644 index 000000000..6d746467b --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/CompletionsRepository.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Repository contract for handling stateless completion requests from the current MCP + * request context. + * + * @author Taewoong Kim + */ +public interface CompletionsRepository { + + /** + * Complete the request for the current request context. + * @param request the completion request + * @param transportContext the transport context for the current request + * @return the completion result + */ + McpSchema.CompleteResult complete(McpSchema.CompleteRequest request, McpTransportContext transportContext); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index a2333aedb..8e2075b2c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2025 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server; @@ -139,6 +139,7 @@ * @author Christian Tzolov * @author Dariusz Jędrzejczyk * @author Jihoon Kim + * @author Taewoong Kim * @see McpAsyncServer * @see McpSyncServer * @see McpServerTransportProvider @@ -1605,13 +1606,13 @@ public StatelessAsyncSpecification capabilities(McpSchema.ServerCapabilities ser /** * Adds a single tool with its implementation handler to the server. This is a * convenience method for registering individual tools without creating a - * {@link McpServerFeatures.AsyncToolSpecification} explicitly. + * {@link McpStatelessServerFeatures.AsyncToolSpecification} explicitly. * @param tool The tool definition including name, description, and schema. Must * not be null. * @param callHandler The function that implements the tool's logic. Must not be - * null. The function's first argument is an {@link McpAsyncServerExchange} upon - * which the server can interact with the connected client. The second argument is - * the {@link McpSchema.CallToolRequest} object containing the tool call + * null. The function's first argument is an {@link McpTransportContext} for the + * current request. The second argument is the {@link McpSchema.CallToolRequest} + * object containing the tool call * @return This builder instance for method chaining * @throws IllegalArgumentException if tool or handler is null */ @@ -1658,9 +1659,9 @@ public StatelessAsyncSpecification tools( *

* Example usage:

{@code
 		 * .tools(
-		 *     McpServerFeatures.AsyncToolSpecification.builder().tool(calculatorTool).callTool(calculatorHandler).build(),
-		 *     McpServerFeatures.AsyncToolSpecification.builder().tool(weatherTool).callTool(weatherHandler).build(),
-		 *     McpServerFeatures.AsyncToolSpecification.builder().tool(fileManagerTool).callTool(fileManagerHandler).build()
+		 *     McpStatelessServerFeatures.AsyncToolSpecification.builder().tool(calculatorTool).callTool(calculatorHandler).build(),
+		 *     McpStatelessServerFeatures.AsyncToolSpecification.builder().tool(weatherTool).callTool(weatherHandler).build(),
+		 *     McpStatelessServerFeatures.AsyncToolSpecification.builder().tool(fileManagerTool).callTool(fileManagerHandler).build()
 		 * )
 		 * }
* @param toolSpecifications The tool specifications to add. Must not be null. @@ -1731,9 +1732,9 @@ public StatelessAsyncSpecification resources( *

* Example usage:

{@code
 		 * .resources(
-		 *     new McpServerFeatures.AsyncResourceSpecification(fileResource, fileHandler),
-		 *     new McpServerFeatures.AsyncResourceSpecification(dbResource, dbHandler),
-		 *     new McpServerFeatures.AsyncResourceSpecification(apiResource, apiHandler)
+		 *     new McpStatelessServerFeatures.AsyncResourceSpecification(fileResource, fileHandler),
+		 *     new McpStatelessServerFeatures.AsyncResourceSpecification(dbResource, dbHandler),
+		 *     new McpStatelessServerFeatures.AsyncResourceSpecification(apiResource, apiHandler)
 		 * )
 		 * }
* @param resourceSpecifications The resource specifications to add. Must not be @@ -1791,9 +1792,9 @@ public StatelessAsyncSpecification resourceTemplates( * *

* Example usage:

{@code
-		 * .prompts(Map.of("analysis", new McpServerFeatures.AsyncPromptSpecification(
+		 * .prompts(Map.of("analysis", new McpStatelessServerFeatures.AsyncPromptSpecification(
 		 *     new Prompt("analysis", "Code analysis template"),
-		 *     request -> Mono.fromSupplier(() -> generateAnalysisPrompt(request))
+		 *     (context, request) -> Mono.fromSupplier(() -> generateAnalysisPrompt(request))
 		 *         .map(GetPromptResult::new)
 		 * )));
 		 * }
@@ -1831,9 +1832,9 @@ public StatelessAsyncSpecification prompts(List * Example usage:
{@code
 		 * .prompts(
-		 *     new McpServerFeatures.AsyncPromptSpecification(analysisPrompt, analysisHandler),
-		 *     new McpServerFeatures.AsyncPromptSpecification(summaryPrompt, summaryHandler),
-		 *     new McpServerFeatures.AsyncPromptSpecification(reviewPrompt, reviewHandler)
+		 *     new McpStatelessServerFeatures.AsyncPromptSpecification(analysisPrompt, analysisHandler),
+		 *     new McpStatelessServerFeatures.AsyncPromptSpecification(summaryPrompt, summaryHandler),
+		 *     new McpStatelessServerFeatures.AsyncPromptSpecification(reviewPrompt, reviewHandler)
 		 * )
 		 * }
* @param prompts The prompt specifications to add. Must not be null. @@ -1908,7 +1909,8 @@ public StatelessAsyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonS public McpStatelessAsyncServer build() { var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, - this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); + this.resources, this.resourceTemplates, this.prompts, this.completions, null, null, null, null, + false, this.instructions); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -1980,6 +1982,14 @@ class StatelessSyncSpecification { final Map completions = new HashMap<>(); + ToolsRepository toolsRepository; + + ResourcesRepository resourcesRepository; + + PromptsRepository promptsRepository; + + CompletionsRepository completionsRepository; + Duration requestTimeout = Duration.ofSeconds(10); // Default timeout public StatelessSyncSpecification(McpStatelessServerTransport transport) { @@ -2105,13 +2115,13 @@ public StatelessSyncSpecification capabilities(McpSchema.ServerCapabilities serv /** * Adds a single tool with its implementation handler to the server. This is a * convenience method for registering individual tools without creating a - * {@link McpServerFeatures.SyncToolSpecification} explicitly. + * {@link McpStatelessServerFeatures.SyncToolSpecification} explicitly. * @param tool The tool definition including name, description, and schema. Must * not be null. * @param callHandler The function that implements the tool's logic. Must not be - * null. The function's first argument is an {@link McpSyncServerExchange} upon - * which the server can interact with the connected client. The second argument is - * the {@link McpSchema.CallToolRequest} object containing the tool call + * null. The function's first argument is an {@link McpTransportContext} for the + * current request. The second argument is the {@link McpSchema.CallToolRequest} + * object containing the tool call * @return This builder instance for method chaining * @throws IllegalArgumentException if tool or handler is null */ @@ -2120,6 +2130,7 @@ public StatelessSyncSpecification toolCall(McpSchema.Tool tool, Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "Handler must not be null"); + assertNoRepository(this.toolsRepository, "toolsRepository", "Static tool registrations"); validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); @@ -2141,6 +2152,7 @@ public StatelessSyncSpecification toolCall(McpSchema.Tool tool, public StatelessSyncSpecification tools( List toolSpecifications) { Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); + assertNoRepository(this.toolsRepository, "toolsRepository", "Static tool registrations"); for (var tool : toolSpecifications) { validateToolName(tool.tool().name()); @@ -2158,9 +2170,9 @@ public StatelessSyncSpecification tools( *

* Example usage:

{@code
 		 * .tools(
-		 *     McpServerFeatures.SyncToolSpecification.builder().tool(calculatorTool).callTool(calculatorHandler).build(),
-		 *     McpServerFeatures.SyncToolSpecification.builder().tool(weatherTool).callTool(weatherHandler).build(),
-		 *     McpServerFeatures.SyncToolSpecification.builder().tool(fileManagerTool).callTool(fileManagerHandler).build()
+		 *     McpStatelessServerFeatures.SyncToolSpecification.builder().tool(calculatorTool).callTool(calculatorHandler).build(),
+		 *     McpStatelessServerFeatures.SyncToolSpecification.builder().tool(weatherTool).callTool(weatherHandler).build(),
+		 *     McpStatelessServerFeatures.SyncToolSpecification.builder().tool(fileManagerTool).callTool(fileManagerHandler).build()
 		 * )
 		 * }
* @param toolSpecifications The tool specifications to add. Must not be null. @@ -2170,6 +2182,7 @@ public StatelessSyncSpecification tools( public StatelessSyncSpecification tools( McpStatelessServerFeatures.SyncToolSpecification... toolSpecifications) { Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); + assertNoRepository(this.toolsRepository, "toolsRepository", "Static tool registrations"); for (var tool : toolSpecifications) { validateToolName(tool.tool().name()); @@ -2189,6 +2202,19 @@ private void assertNoDuplicateTool(String toolName) { } } + /** + * Sets the repository used to list, resolve, and call tools for each stateless + * request. Static tool registrations cannot be mixed with a tools repository. + * @param toolsRepository The tools repository. Must not be null. + * @return This builder instance for method chaining + */ + public StatelessSyncSpecification toolsRepository(ToolsRepository toolsRepository) { + Assert.notNull(toolsRepository, "Tools repository must not be null"); + assertNoStaticRegistrations(!this.tools.isEmpty(), "toolsRepository", "static tool registrations"); + this.toolsRepository = toolsRepository; + return this; + } + /** * Registers multiple resources with their handlers using a Map. This method is * useful when resources are dynamically generated or loaded from a configuration @@ -2202,6 +2228,7 @@ private void assertNoDuplicateTool(String toolName) { public StatelessSyncSpecification resources( Map resourceSpecifications) { Assert.notNull(resourceSpecifications, "Resource handlers map must not be null"); + assertNoRepository(this.resourcesRepository, "resourcesRepository", "Static resource registrations"); this.resources.putAll(resourceSpecifications); return this; } @@ -2218,6 +2245,7 @@ public StatelessSyncSpecification resources( public StatelessSyncSpecification resources( List resourceSpecifications) { Assert.notNull(resourceSpecifications, "Resource handlers list must not be null"); + assertNoRepository(this.resourcesRepository, "resourcesRepository", "Static resource registrations"); for (var resource : resourceSpecifications) { this.resources.put(resource.resource().uri(), resource); } @@ -2231,9 +2259,9 @@ public StatelessSyncSpecification resources( *

* Example usage:

{@code
 		 * .resources(
-		 *     new McpServerFeatures.SyncResourceSpecification(fileResource, fileHandler),
-		 *     new McpServerFeatures.SyncResourceSpecification(dbResource, dbHandler),
-		 *     new McpServerFeatures.SyncResourceSpecification(apiResource, apiHandler)
+		 *     new McpStatelessServerFeatures.SyncResourceSpecification(fileResource, fileHandler),
+		 *     new McpStatelessServerFeatures.SyncResourceSpecification(dbResource, dbHandler),
+		 *     new McpStatelessServerFeatures.SyncResourceSpecification(apiResource, apiHandler)
 		 * )
 		 * }
* @param resourceSpecifications The resource specifications to add. Must not be @@ -2244,6 +2272,7 @@ public StatelessSyncSpecification resources( public StatelessSyncSpecification resources( McpStatelessServerFeatures.SyncResourceSpecification... resourceSpecifications) { Assert.notNull(resourceSpecifications, "Resource handlers list must not be null"); + assertNoRepository(this.resourcesRepository, "resourcesRepository", "Static resource registrations"); for (var resource : resourceSpecifications) { this.resources.put(resource.resource().uri(), resource); } @@ -2261,6 +2290,8 @@ public StatelessSyncSpecification resources( public StatelessSyncSpecification resourceTemplates( List resourceTemplatesSpec) { Assert.notNull(resourceTemplatesSpec, "Resource templates must not be null"); + assertNoRepository(this.resourcesRepository, "resourcesRepository", + "Static resource template registrations"); for (var resourceTemplate : resourceTemplatesSpec) { this.resourceTemplates.put(resourceTemplate.resourceTemplate().uriTemplate(), resourceTemplate); } @@ -2278,12 +2309,29 @@ public StatelessSyncSpecification resourceTemplates( public StatelessSyncSpecification resourceTemplates( McpStatelessServerFeatures.SyncResourceTemplateSpecification... resourceTemplates) { Assert.notNull(resourceTemplates, "Resource templates must not be null"); + assertNoRepository(this.resourcesRepository, "resourcesRepository", + "Static resource template registrations"); for (McpStatelessServerFeatures.SyncResourceTemplateSpecification resourceTemplate : resourceTemplates) { this.resourceTemplates.put(resourceTemplate.resourceTemplate().uriTemplate(), resourceTemplate); } return this; } + /** + * Sets the repository used to list, resolve, and read resources and resource + * templates for each stateless request. Static resource and resource-template + * registrations cannot be mixed with a resources repository. + * @param resourcesRepository The resources repository. Must not be null. + * @return This builder instance for method chaining + */ + public StatelessSyncSpecification resourcesRepository(ResourcesRepository resourcesRepository) { + Assert.notNull(resourcesRepository, "Resources repository must not be null"); + assertNoStaticRegistrations(!this.resources.isEmpty() || !this.resourceTemplates.isEmpty(), + "resourcesRepository", "static resource or resource template registrations"); + this.resourcesRepository = resourcesRepository; + return this; + } + /** * Registers multiple prompts with their handlers using a Map. This method is * useful when prompts are dynamically generated or loaded from a configuration @@ -2291,10 +2339,9 @@ public StatelessSyncSpecification resourceTemplates( * *

* Example usage:

{@code
-		 * .prompts(Map.of("analysis", new McpServerFeatures.SyncPromptSpecification(
+		 * .prompts(Map.of("analysis", new McpStatelessServerFeatures.SyncPromptSpecification(
 		 *     new Prompt("analysis", "Code analysis template"),
-		 *     request -> Mono.fromSupplier(() -> generateAnalysisPrompt(request))
-		 *         .map(GetPromptResult::new)
+		 *     (context, request) -> generateAnalysisPrompt(request)
 		 * )));
 		 * }
* @param prompts Map of prompt name to specification. Must not be null. @@ -2304,6 +2351,7 @@ public StatelessSyncSpecification resourceTemplates( public StatelessSyncSpecification prompts( Map prompts) { Assert.notNull(prompts, "Prompts map must not be null"); + assertNoRepository(this.promptsRepository, "promptsRepository", "Static prompt registrations"); this.prompts.putAll(prompts); return this; } @@ -2318,6 +2366,7 @@ public StatelessSyncSpecification prompts( */ public StatelessSyncSpecification prompts(List prompts) { Assert.notNull(prompts, "Prompts list must not be null"); + assertNoRepository(this.promptsRepository, "promptsRepository", "Static prompt registrations"); for (var prompt : prompts) { this.prompts.put(prompt.prompt().name(), prompt); } @@ -2331,9 +2380,9 @@ public StatelessSyncSpecification prompts(List * Example usage:
{@code
 		 * .prompts(
-		 *     new McpServerFeatures.SyncPromptSpecification(analysisPrompt, analysisHandler),
-		 *     new McpServerFeatures.SyncPromptSpecification(summaryPrompt, summaryHandler),
-		 *     new McpServerFeatures.SyncPromptSpecification(reviewPrompt, reviewHandler)
+		 *     new McpStatelessServerFeatures.SyncPromptSpecification(analysisPrompt, analysisHandler),
+		 *     new McpStatelessServerFeatures.SyncPromptSpecification(summaryPrompt, summaryHandler),
+		 *     new McpStatelessServerFeatures.SyncPromptSpecification(reviewPrompt, reviewHandler)
 		 * )
 		 * }
* @param prompts The prompt specifications to add. Must not be null. @@ -2342,12 +2391,26 @@ public StatelessSyncSpecification prompts(List completions) { Assert.notNull(completions, "Completions list must not be null"); + assertNoRepository(this.completionsRepository, "completionsRepository", "Static completion registrations"); for (var completion : completions) { this.completions.put(completion.referenceKey(), completion); } @@ -2374,12 +2438,27 @@ public StatelessSyncSpecification completions( public StatelessSyncSpecification completions( McpStatelessServerFeatures.SyncCompletionSpecification... completions) { Assert.notNull(completions, "Completions list must not be null"); + assertNoRepository(this.completionsRepository, "completionsRepository", "Static completion registrations"); for (var completion : completions) { this.completions.put(completion.referenceKey(), completion); } return this; } + /** + * Sets the repository used to complete stateless completion requests. Static + * completion registrations cannot be mixed with a completions repository. + * @param completionsRepository The completions repository. Must not be null. + * @return This builder instance for method chaining + */ + public StatelessSyncSpecification completionsRepository(CompletionsRepository completionsRepository) { + Assert.notNull(completionsRepository, "Completions repository must not be null"); + assertNoStaticRegistrations(!this.completions.isEmpty(), "completionsRepository", + "static completion registrations"); + this.completionsRepository = completionsRepository; + return this; + } + /** * Sets the JsonMapper to use for serializing and deserializing JSON messages. * @param jsonMapper the mapper to use. Must not be null. @@ -2407,7 +2486,7 @@ public StatelessSyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonSc } /** - * Enable on "immediate execution" of the operations on the underlying + * Enables immediate execution of operations on the underlying * {@link McpStatelessAsyncServer}. Defaults to false, which does blocking code * offloading to prevent accidental blocking of the non-blocking transport. *

@@ -2424,7 +2503,8 @@ public StatelessSyncSpecification immediateExecution(boolean immediateExecution) public McpStatelessSyncServer build() { var syncFeatures = new McpStatelessServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools, - this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); + this.resources, this.resourceTemplates, this.prompts, this.completions, this.toolsRepository, + this.resourcesRepository, this.promptsRepository, this.completionsRepository, this.instructions); var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -2434,11 +2514,26 @@ public McpStatelessSyncServer build() { var asyncServer = new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); - return new McpStatelessSyncServer(asyncServer, this.immediateExecution); + return new McpStatelessSyncServer(asyncServer); } } + private static void assertNoStaticRegistrations(boolean hasStaticRegistrations, String repositoryName, + String registrationsName) { + if (hasStaticRegistrations) { + throw new IllegalStateException( + repositoryName + " cannot be configured together with " + registrationsName); + } + } + + private static void assertNoRepository(Object repository, String repositoryName, String registrationsName) { + if (repository != null) { + throw new IllegalStateException( + registrationsName + " cannot be configured together with " + repositoryName); + } + } + private static void validateAsyncToolSchemas(JsonSchemaValidator validator, List tools) { tools.forEach(spec -> validateToolSchema(validator, spec.tool())); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index c6105267d..c43722ed3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -47,6 +47,7 @@ * knowledge and can serve the clients with the capabilities it supports. * * @author Dariusz Jędrzejczyk + * @author Taewoong Kim */ public class McpStatelessAsyncServer { @@ -72,6 +73,18 @@ public class McpStatelessAsyncServer { private final ConcurrentHashMap completions = new ConcurrentHashMap<>(); + private final ToolsRepository toolsRepository; + + private final ResourcesRepository resourcesRepository; + + private final PromptsRepository promptsRepository; + + private final CompletionsRepository completionsRepository; + + private final boolean immediateExecution; + + private final SyncRepositoryCallAdapter repositoryCallAdapter; + private List protocolVersions; private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory(); @@ -94,6 +107,12 @@ public class McpStatelessAsyncServer { this.resourceTemplates.putAll(features.resourceTemplates()); this.prompts.putAll(features.prompts()); this.completions.putAll(features.completions()); + this.toolsRepository = features.toolsRepository(); + this.resourcesRepository = features.resourcesRepository(); + this.promptsRepository = features.promptsRepository(); + this.completionsRepository = features.completionsRepository(); + this.immediateExecution = features.immediateExecution(); + this.repositoryCallAdapter = new SyncRepositoryCallAdapter(this.immediateExecution); this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; @@ -187,6 +206,14 @@ public McpSchema.Implementation getServerInfo() { return this.serverInfo; } + private McpSchema.PaginatedRequest paginatedRequest(Object params) { + if (params == null) { + return new McpSchema.PaginatedRequest(); + } + return this.jsonMapper.convertValue(params, new TypeRef<>() { + }); + } + /** * Gracefully closes the server, allowing any in-progress operations to complete. * @return A Mono that completes when the server has been closed @@ -235,6 +262,94 @@ private static McpStatelessServerFeatures.AsyncToolSpecification withStructuredO toolSpecification.callHandler())); } + private void assertToolSpecification(McpStatelessServerFeatures.AsyncToolSpecification toolSpecification) { + if (toolSpecification == null) { + throw new IllegalArgumentException("Tool specification must not be null"); + } + assertToolSpecification(toolSpecification.tool(), toolSpecification.callHandler()); + } + + private void assertToolSpecification(McpStatelessServerFeatures.SyncToolSpecification toolSpecification) { + if (toolSpecification == null) { + throw new IllegalArgumentException("Tool specification must not be null"); + } + assertToolSpecification(toolSpecification.tool(), toolSpecification.callHandler()); + } + + private void assertToolSpecification(McpSchema.Tool tool, Object callHandler) { + if (tool == null) { + throw new IllegalArgumentException("Tool must not be null"); + } + if (callHandler == null) { + throw new IllegalArgumentException("Tool call handler must not be null"); + } + if (this.serverCapabilities.tools() == null) { + throw new IllegalStateException("Server must be configured with tool capabilities"); + } + this.jsonSchemaValidator.assertConforms("Tool '" + tool.name() + "' inputSchema", tool.inputSchema()); + this.jsonSchemaValidator.assertConforms("Tool '" + tool.name() + "' outputSchema", tool.outputSchema()); + } + + private static CallToolResult validateToolOutput(JsonSchemaValidator jsonSchemaValidator, + Map outputSchema, McpSchema.CallToolRequest request, CallToolResult result) { + + if (Boolean.TRUE.equals(result.isError())) { + // If the tool call resulted in an error, skip further validation + return result; + } + + if (outputSchema == null) { + if (result.structuredContent() != null) { + logger.warn( + "Tool call with no outputSchema is not expected to have a result with structured content, but got: {}", + result.structuredContent()); + } + // Pass through. No validation is required if no output schema is + // provided. + return result; + } + + // If an output schema is provided, servers MUST provide structured + // results that conform to this schema. + // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema + if (result.structuredContent() == null) { + String content = "Response missing structured content which is expected when calling tool with non-empty outputSchema"; + logger.warn(content); + return CallToolResult.builder() + .content(List.of(McpSchema.TextContent.builder(content).build())) + .isError(true) + .build(); + } + + // Validate the result against the output schema + var validation = jsonSchemaValidator.validate(outputSchema, result.structuredContent()); + + if (!validation.valid()) { + String message = "Tool (" + request.name() + ") output validation failed: " + validation.errorMessage(); + logger.warn(message); + return CallToolResult.builder() + .content(List.of(McpSchema.TextContent.builder(message).build())) + .isError(true) + .build(); + } + + if (Utils.isEmpty(result.content())) { + // For backwards compatibility, a tool that returns structured + // content SHOULD also return functionally equivalent unstructured + // content. (For example, serialized JSON can be returned in a + // TextContent block.) + // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content + + return CallToolResult.builder() + .content(List.of(McpSchema.TextContent.builder(validation.jsonStructuredOutput()).build())) + .isError(result.isError()) + .structuredContent(result.structuredContent()) + .build(); + } + + return result; + } + private static class StructuredOutputCallToolHandler implements BiFunction> { @@ -259,65 +374,8 @@ public StructuredOutputCallToolHandler(JsonSchemaValidator jsonSchemaValidator, @Override public Mono apply(McpTransportContext transportContext, McpSchema.CallToolRequest request) { - return this.delegateHandler.apply(transportContext, request).map(result -> { - - if (Boolean.TRUE.equals(result.isError())) { - // If the tool call resulted in an error, skip further validation - return result; - } - - if (outputSchema == null) { - if (result.structuredContent() != null) { - logger.warn( - "Tool call with no outputSchema is not expected to have a result with structured content, but got: {}", - result.structuredContent()); - } - // Pass through. No validation is required if no output schema is - // provided. - return result; - } - - // If an output schema is provided, servers MUST provide structured - // results that conform to this schema. - // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema - if (result.structuredContent() == null) { - String content = "Response missing structured content which is expected when calling tool with non-empty outputSchema"; - logger.warn(content); - return CallToolResult.builder() - .content(List.of(McpSchema.TextContent.builder(content).build())) - .isError(true) - .build(); - } - - // Validate the result against the output schema - var validation = this.jsonSchemaValidator.validate(outputSchema, result.structuredContent()); - - if (!validation.valid()) { - String message = "Tool (" + request.name() + ") output validation failed: " - + validation.errorMessage(); - logger.warn(message); - return CallToolResult.builder() - .content(List.of(McpSchema.TextContent.builder(message).build())) - .isError(true) - .build(); - } - - if (Utils.isEmpty(result.content())) { - // For backwards compatibility, a tool that returns structured - // content SHOULD also return functionally equivalent unstructured - // content. (For example, serialized JSON can be returned in a - // TextContent block.) - // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content - - return CallToolResult.builder() - .content(List.of(McpSchema.TextContent.builder(validation.jsonStructuredOutput()).build())) - .isError(result.isError()) - .structuredContent(result.structuredContent()) - .build(); - } - - return result; - }); + return this.delegateHandler.apply(transportContext, request) + .map(result -> validateToolOutput(this.jsonSchemaValidator, this.outputSchema, request, result)); } } @@ -325,28 +383,17 @@ public Mono apply(McpTransportContext transportContext, McpSchem /** * Add a new tool specification at runtime. * @param toolSpecification The tool specification to add - * @return Mono that completes when clients have been notified of the change + * @return Mono that completes when the server has been updated */ public Mono addTool(McpStatelessServerFeatures.AsyncToolSpecification toolSpecification) { - if (toolSpecification == null) { - return Mono.error(new IllegalArgumentException("Tool specification must not be null")); - } - if (toolSpecification.tool() == null) { - return Mono.error(new IllegalArgumentException("Tool must not be null")); - } - if (toolSpecification.callHandler() == null) { - return Mono.error(new IllegalArgumentException("Tool call handler must not be null")); - } - if (this.serverCapabilities.tools() == null) { - return Mono.error(new IllegalStateException("Server must be configured with tool capabilities")); + if (this.toolsRepository != null) { + return Mono.error(new IllegalStateException( + "Server is configured with a tools repository and cannot accept async tool registrations")); } - try { - var t = toolSpecification.tool(); - this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' inputSchema", t.inputSchema()); - this.jsonSchemaValidator.assertConforms("Tool '" + t.name() + "' outputSchema", t.outputSchema()); + assertToolSpecification(toolSpecification); } - catch (IllegalArgumentException e) { + catch (RuntimeException e) { return Mono.error(e); } @@ -365,18 +412,39 @@ public Mono addTool(McpStatelessServerFeatures.AsyncToolSpecification tool }); } + Mono addTool(McpStatelessServerFeatures.SyncToolSpecification toolSpecification) { + if (this.toolsRepository != null) { + try { + assertToolSpecification(toolSpecification); + } + catch (RuntimeException e) { + return Mono.error(e); + } + return this.repositoryCallAdapter.run(() -> this.toolsRepository.addTool(toolSpecification)); + } + return addTool( + McpStatelessServerFeatures.AsyncToolSpecification.fromSync(toolSpecification, this.immediateExecution)); + } + /** - * List all registered tools. - * @return A Flux stream of all registered tools + * List tools without a client request context. + * @return A Flux stream of context-free visible tools */ public Flux listTools() { + if (this.toolsRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.toolsRepository + .listTools(new McpSchema.PaginatedRequest(), McpTransportContext.EMPTY) + .tools()) + .flatMapIterable(tools -> tools); + } return Flux.fromIterable(this.tools).map(McpStatelessServerFeatures.AsyncToolSpecification::tool); } /** * Remove a tool handler at runtime. * @param toolName The name of the tool handler to remove - * @return Mono that completes when clients have been notified of the change + * @return Mono that completes when the server has been updated */ public Mono removeTool(String toolName) { if (toolName == null) { @@ -385,10 +453,12 @@ public Mono removeTool(String toolName) { if (this.serverCapabilities.tools() == null) { return Mono.error(new IllegalStateException("Server must be configured with tool capabilities")); } + if (this.toolsRepository != null) { + return this.repositoryCallAdapter.invoke(() -> this.toolsRepository.removeTool(toolName)).then(); + } return Mono.defer(() -> { if (this.tools.removeIf(toolSpecification -> toolSpecification.tool().name().equals(toolName))) { - logger.debug("Removed tool handler: {}", toolName); } else { @@ -401,6 +471,10 @@ public Mono removeTool(String toolName) { private McpStatelessRequestHandler toolsListRequestHandler() { return (ctx, params) -> { + if (this.toolsRepository != null) { + McpSchema.PaginatedRequest request = paginatedRequest(params); + return this.repositoryCallAdapter.invoke(() -> this.toolsRepository.listTools(request, ctx)); + } List tools = this.tools.stream() .map(McpStatelessServerFeatures.AsyncToolSpecification::tool) .toList(); @@ -414,6 +488,16 @@ private McpStatelessRequestHandler toolsCallRequestHandler() { new TypeRef() { }); + if (this.toolsRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.toolsRepository.resolveTool(callToolRequest.name(), ctx)) + .flatMap(tool -> tool.map(resolvedTool -> callRepositoryTool(ctx, callToolRequest, resolvedTool)) + .orElseGet(() -> Mono.error(McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("Unknown tool: invalid_tool_name") + .data("Tool not found: " + callToolRequest.name()) + .build()))); + } + Optional toolSpecification = this.tools.stream() .filter(tr -> callToolRequest.name().equals(tr.tool().name())) .findAny(); @@ -436,6 +520,19 @@ private McpStatelessRequestHandler toolsCallRequestHandler() { }; } + private Mono callRepositoryTool(McpTransportContext transportContext, + McpSchema.CallToolRequest callToolRequest, McpSchema.Tool tool) { + + CallToolResult validationError = ToolInputValidator.validate(tool, callToolRequest.arguments(), + this.validateToolInputs, this.jsonSchemaValidator); + if (validationError != null) { + return Mono.just(validationError); + } + + return this.repositoryCallAdapter.invoke(() -> this.toolsRepository.callTool(callToolRequest, transportContext)) + .map(result -> validateToolOutput(this.jsonSchemaValidator, tool.outputSchema(), callToolRequest, result)); + } + // --------------------------------------- // Resource Management // --------------------------------------- @@ -443,9 +540,13 @@ private McpStatelessRequestHandler toolsCallRequestHandler() { /** * Add a new resource handler at runtime. * @param resourceSpecification The resource handler to add - * @return Mono that completes when clients have been notified of the change + * @return Mono that completes when the server has been updated */ public Mono addResource(McpStatelessServerFeatures.AsyncResourceSpecification resourceSpecification) { + if (this.resourcesRepository != null) { + return Mono.error(new IllegalStateException( + "Server is configured with a resources repository and cannot accept async resource registrations")); + } if (resourceSpecification == null || resourceSpecification.resource() == null) { return Mono.error(new IllegalArgumentException("Resource must not be null")); } @@ -466,11 +567,32 @@ public Mono addResource(McpStatelessServerFeatures.AsyncResourceSpecificat }); } + Mono addResource(McpStatelessServerFeatures.SyncResourceSpecification resourceSpecification) { + if (this.resourcesRepository != null) { + if (resourceSpecification == null || resourceSpecification.resource() == null) { + return Mono.error(new IllegalArgumentException("Resource must not be null")); + } + if (this.serverCapabilities.resources() == null) { + return Mono.error(new IllegalStateException("Server must be configured with resource capabilities")); + } + return this.repositoryCallAdapter.run(() -> this.resourcesRepository.addResource(resourceSpecification)); + } + return addResource(McpStatelessServerFeatures.AsyncResourceSpecification.fromSync(resourceSpecification, + this.immediateExecution)); + } + /** - * List all registered resources. - * @return A Flux stream of all registered resources + * List resources without a client request context. + * @return A Flux stream of context-free visible resources */ public Flux listResources() { + if (this.resourcesRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.resourcesRepository + .listResources(new McpSchema.PaginatedRequest(), McpTransportContext.EMPTY) + .resources()) + .flatMapIterable(resources -> resources); + } return Flux.fromIterable(this.resources.values()) .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource); } @@ -478,7 +600,7 @@ public Flux listResources() { /** * Remove a resource handler at runtime. * @param resourceUri The URI of the resource handler to remove - * @return Mono that completes when clients have been notified of the change + * @return Mono that completes when the server has been updated */ public Mono removeResource(String resourceUri) { if (resourceUri == null) { @@ -487,6 +609,9 @@ public Mono removeResource(String resourceUri) { if (this.serverCapabilities.resources() == null) { return Mono.error(new IllegalStateException("Server must be configured with resource capabilities")); } + if (this.resourcesRepository != null) { + return this.repositoryCallAdapter.invoke(() -> this.resourcesRepository.removeResource(resourceUri)).then(); + } return Mono.defer(() -> { McpStatelessServerFeatures.AsyncResourceSpecification removed = this.resources.remove(resourceUri); @@ -503,10 +628,14 @@ public Mono removeResource(String resourceUri) { /** * Add a new resource template at runtime. * @param resourceTemplateSpecification The resource template to add - * @return Mono that completes when clients have been notified of the change + * @return Mono that completes when the server has been updated */ public Mono addResourceTemplate( McpStatelessServerFeatures.AsyncResourceTemplateSpecification resourceTemplateSpecification) { + if (this.resourcesRepository != null) { + return Mono.error(new IllegalStateException( + "Server is configured with a resources repository and cannot accept async resource template registrations")); + } if (this.serverCapabilities.resources() == null) { return Mono.error(new IllegalStateException( @@ -528,11 +657,32 @@ public Mono addResourceTemplate( }); } + Mono addResourceTemplate( + McpStatelessServerFeatures.SyncResourceTemplateSpecification resourceTemplateSpecification) { + if (this.resourcesRepository != null) { + if (this.serverCapabilities.resources() == null) { + return Mono.error(new IllegalStateException( + "Server must be configured with resource capabilities to allow adding resource templates")); + } + return this.repositoryCallAdapter + .run(() -> this.resourcesRepository.addResourceTemplate(resourceTemplateSpecification)); + } + return addResourceTemplate(McpStatelessServerFeatures.AsyncResourceTemplateSpecification + .fromSync(resourceTemplateSpecification, this.immediateExecution)); + } + /** - * List all registered resource templates. - * @return A Flux stream of all registered resource templates + * List resource templates without a client request context. + * @return A Flux stream of context-free visible resource templates */ public Flux listResourceTemplates() { + if (this.resourcesRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.resourcesRepository + .listResourceTemplates(new McpSchema.PaginatedRequest(), McpTransportContext.EMPTY) + .resourceTemplates()) + .flatMapIterable(resourceTemplates -> resourceTemplates); + } return Flux.fromIterable(this.resourceTemplates.values()) .map(McpStatelessServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate); } @@ -540,7 +690,7 @@ public Flux listResourceTemplates() { /** * Remove a resource template at runtime. * @param uriTemplate The URI template of the resource template to remove - * @return Mono that completes when clients have been notified of the change + * @return Mono that completes when the server has been updated */ public Mono removeResourceTemplate(String uriTemplate) { @@ -548,6 +698,10 @@ public Mono removeResourceTemplate(String uriTemplate) { return Mono.error(new IllegalStateException( "Server must be configured with resource capabilities to allow removing resource templates")); } + if (this.resourcesRepository != null) { + return this.repositoryCallAdapter.invoke(() -> this.resourcesRepository.removeResourceTemplate(uriTemplate)) + .then(); + } return Mono.defer(() -> { McpStatelessServerFeatures.AsyncResourceTemplateSpecification removed = this.resourceTemplates @@ -564,6 +718,10 @@ public Mono removeResourceTemplate(String uriTemplate) { private McpStatelessRequestHandler resourcesListRequestHandler() { return (ctx, params) -> { + if (this.resourcesRepository != null) { + McpSchema.PaginatedRequest request = paginatedRequest(params); + return this.repositoryCallAdapter.invoke(() -> this.resourcesRepository.listResources(request, ctx)); + } var resourceList = this.resources.values() .stream() .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) @@ -573,7 +731,12 @@ private McpStatelessRequestHandler resourcesListR } private McpStatelessRequestHandler resourceTemplateListRequestHandler() { - return (exchange, params) -> { + return (ctx, params) -> { + if (this.resourcesRepository != null) { + McpSchema.PaginatedRequest request = paginatedRequest(params); + return this.repositoryCallAdapter + .invoke(() -> this.resourcesRepository.listResourceTemplates(request, ctx)); + } var resourceList = this.resourceTemplates.values() .stream() .map(AsyncResourceTemplateSpecification::resourceTemplate) @@ -588,6 +751,21 @@ private McpStatelessRequestHandler resourcesReadRe }); var resourceUri = resourceRequest.uri(); + if (this.resourcesRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.resourcesRepository.resolveResource(resourceUri, ctx)) + .flatMap(resource -> { + if (resource.isPresent()) { + return readRepositoryResource(resourceRequest, ctx); + } + return this.repositoryCallAdapter + .invoke(() -> this.resourcesRepository.resolveResourceTemplate(resourceUri, ctx)) + .flatMap(resourceTemplate -> resourceTemplate.isPresent() + ? readRepositoryResource(resourceRequest, ctx) + : Mono.error(RESOURCE_NOT_FOUND.apply(resourceUri))); + }); + } + // First try to find a static resource specification // Static resources have exact URIs return this.findResourceSpecification(resourceUri) @@ -603,6 +781,12 @@ private McpStatelessRequestHandler resourcesReadRe }; } + private Mono readRepositoryResource(McpSchema.ReadResourceRequest resourceRequest, + McpTransportContext transportContext) { + return this.repositoryCallAdapter + .invoke(() -> this.resourcesRepository.readResource(resourceRequest, transportContext)); + } + private Optional findResourceSpecification(String uri) { var result = this.resources.values() .stream() @@ -626,9 +810,13 @@ private Optional /** * Add a new prompt handler at runtime. * @param promptSpecification The prompt handler to add - * @return Mono that completes when clients have been notified of the change + * @return Mono that completes when the server has been updated */ public Mono addPrompt(McpStatelessServerFeatures.AsyncPromptSpecification promptSpecification) { + if (this.promptsRepository != null) { + return Mono.error(new IllegalStateException( + "Server is configured with a prompts repository and cannot accept async prompt registrations")); + } if (promptSpecification == null) { return Mono.error(new IllegalArgumentException("Prompt specification must not be null")); } @@ -649,11 +837,32 @@ public Mono addPrompt(McpStatelessServerFeatures.AsyncPromptSpecification }); } + Mono addPrompt(McpStatelessServerFeatures.SyncPromptSpecification promptSpecification) { + if (this.promptsRepository != null) { + if (promptSpecification == null) { + return Mono.error(new IllegalArgumentException("Prompt specification must not be null")); + } + if (this.serverCapabilities.prompts() == null) { + return Mono.error(new IllegalStateException("Server must be configured with prompt capabilities")); + } + return this.repositoryCallAdapter.run(() -> this.promptsRepository.addPrompt(promptSpecification)); + } + return addPrompt(McpStatelessServerFeatures.AsyncPromptSpecification.fromSync(promptSpecification, + this.immediateExecution)); + } + /** - * List all registered prompts. - * @return A Flux stream of all registered prompts + * List prompts without a client request context. + * @return A Flux stream of context-free visible prompts */ public Flux listPrompts() { + if (this.promptsRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.promptsRepository + .listPrompts(new McpSchema.PaginatedRequest(), McpTransportContext.EMPTY) + .prompts()) + .flatMapIterable(prompts -> prompts); + } return Flux.fromIterable(this.prompts.values()) .map(McpStatelessServerFeatures.AsyncPromptSpecification::prompt); } @@ -661,7 +870,7 @@ public Flux listPrompts() { /** * Remove a prompt handler at runtime. * @param promptName The name of the prompt handler to remove - * @return Mono that completes when clients have been notified of the change + * @return Mono that completes when the server has been updated */ public Mono removePrompt(String promptName) { if (promptName == null) { @@ -670,6 +879,9 @@ public Mono removePrompt(String promptName) { if (this.serverCapabilities.prompts() == null) { return Mono.error(new IllegalStateException("Server must be configured with prompt capabilities")); } + if (this.promptsRepository != null) { + return this.repositoryCallAdapter.invoke(() -> this.promptsRepository.removePrompt(promptName)).then(); + } return Mono.defer(() -> { McpStatelessServerFeatures.AsyncPromptSpecification removed = this.prompts.remove(promptName); @@ -688,6 +900,10 @@ public Mono removePrompt(String promptName) { private McpStatelessRequestHandler promptsListRequestHandler() { return (ctx, params) -> { + if (this.promptsRepository != null) { + McpSchema.PaginatedRequest request = paginatedRequest(params); + return this.repositoryCallAdapter.invoke(() -> this.promptsRepository.listPrompts(request, ctx)); + } // TODO: Implement pagination // McpSchema.PaginatedRequest request = objectMapper.convertValue(params, // new TypeReference() { @@ -708,6 +924,18 @@ private McpStatelessRequestHandler promptsGetRequestH new TypeRef() { }); + if (this.promptsRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.promptsRepository.resolvePrompt(promptRequest.name(), ctx)) + .flatMap(prompt -> prompt + .map(resolvedPrompt -> this.repositoryCallAdapter + .invoke(() -> this.promptsRepository.getPrompt(promptRequest, ctx))) + .orElseGet(() -> Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Invalid prompt name") + .data("Prompt not found: " + promptRequest.name()) + .build()))); + } + // Implement prompt retrieval logic here McpStatelessServerFeatures.AsyncPromptSpecification specification = this.prompts.get(promptRequest.name()); if (specification == null) { @@ -721,8 +949,10 @@ private McpStatelessRequestHandler promptsGetRequestH }; } - private static final Mono EMPTY_COMPLETION_RESULT = Mono - .just(new McpSchema.CompleteResult(new CompleteCompletion(List.of(), 0, false))); + private static final McpSchema.CompleteResult EMPTY_COMPLETION = new McpSchema.CompleteResult( + new CompleteCompletion(List.of(), 0, false)); + + private static final Mono EMPTY_COMPLETION_RESULT = Mono.just(EMPTY_COMPLETION); private McpStatelessRequestHandler completionCompleteRequestHandler() { return (ctx, params) -> { @@ -744,84 +974,130 @@ private McpStatelessRequestHandler completionCompleteR String argumentName = request.argument().name(); - // Check if valid a Prompt exists for this completion request - if (type.equals(PromptReference.TYPE) - && request.ref() instanceof McpSchema.PromptReference promptReference) { + return validateCompletionReference(ctx, request, type, argumentName) + .flatMap(result -> result.map(Mono::just).orElseGet(() -> { + if (this.completionsRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.completionsRepository.complete(request, ctx)); + } + return resolveCompletionSpecification(request.ref()) + .flatMap(specification -> specification.completionHandler().apply(ctx, request)); + })); + }; + } + + private Mono> validateCompletionReference(McpTransportContext transportContext, + McpSchema.CompleteRequest request, String type, String argumentName) { - McpStatelessServerFeatures.AsyncPromptSpecification promptSpec = this.prompts - .get(promptReference.name()); - if (promptSpec == null) { + if (type.equals(PromptReference.TYPE) && request.ref() instanceof McpSchema.PromptReference promptReference) { + return resolvePrompt(promptReference.name(), transportContext).flatMap(prompt -> { + if (prompt.isEmpty()) { return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) .message("Prompt not found: " + promptReference.name()) .build()); } - List arguments = promptSpec.prompt().arguments(); - if (arguments == null - || !arguments.stream().filter(arg -> arg.name().equals(argumentName)).findFirst().isPresent()) { + List arguments = prompt.get().arguments(); + if (arguments == null || arguments.stream().noneMatch(arg -> arg.name().equals(argumentName))) { logger.warn("Argument not found: {} in prompt: {}", argumentName, promptReference.name()); - - return EMPTY_COMPLETION_RESULT; + return Mono.just(Optional.of(EMPTY_COMPLETION)); } - } - // Check if valid Resource or ResourceTemplate exists for this completion - // request - if (type.equals(ResourceReference.TYPE) - && request.ref() instanceof McpSchema.ResourceReference resourceReference) { + return Mono.just(Optional.empty()); + }); + } - var uriTemplateManager = uriTemplateManagerFactory.create(resourceReference.uri()); + if (type.equals(ResourceReference.TYPE) + && request.ref() instanceof McpSchema.ResourceReference resourceReference) { - if (!uriTemplateManager.isUriTemplate(resourceReference.uri())) { - // Attempting to autocomplete a fixed resource URI is not an error in - // the spec (but probably should be). - return EMPTY_COMPLETION_RESULT; - } + var uriTemplateManager = uriTemplateManagerFactory.create(resourceReference.uri()); - McpStatelessServerFeatures.AsyncResourceSpecification resourceSpec = this - .findResourceSpecification(resourceReference.uri()) - .orElse(null); + if (!uriTemplateManager.isUriTemplate(resourceReference.uri())) { + // Attempting to autocomplete a fixed resource URI is not an error in + // the spec (but probably should be). + return Mono.just(Optional.of(EMPTY_COMPLETION)); + } - if (resourceSpec != null) { - if (!uriTemplateManagerFactory.create(resourceSpec.resource().uri()) - .getVariableNames() - .contains(argumentName)) { + return validateResourceCompletionReference(resourceReference, argumentName, transportContext); + } - return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) - .message("Argument not found: " + argumentName + " in resource: " + resourceReference.uri()) - .build()); - } - } - else { - var templateSpec = this.findResourceTemplateSpecification(resourceReference.uri()).orElse(null); - if (templateSpec != null) { - - if (!uriTemplateManagerFactory.create(templateSpec.resourceTemplate().uriTemplate()) - .getVariableNames() - .contains(argumentName)) { - - return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) - .message("Argument not found: " + argumentName + " in resource template: " - + resourceReference.uri()) - .build()); - } - } - else { - return Mono.error(RESOURCE_NOT_FOUND.apply(resourceReference.uri())); - } + return Mono.just(Optional.empty()); + } + + private Mono> validateResourceCompletionReference( + McpSchema.ResourceReference resourceReference, String argumentName, McpTransportContext transportContext) { + + return resolveResource(resourceReference.uri(), transportContext).flatMap(resource -> { + if (resource.isPresent()) { + if (!uriTemplateManagerFactory.create(resource.get().uri()).getVariableNames().contains(argumentName)) { + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Argument not found: " + argumentName + " in resource: " + resourceReference.uri()) + .build()); } + return Mono.just(Optional.empty()); } - McpStatelessServerFeatures.AsyncCompletionSpecification specification = this.completions.get(request.ref()); + return resolveResourceTemplate(resourceReference.uri(), transportContext).flatMap(resourceTemplate -> { + if (resourceTemplate.isEmpty()) { + return Mono.error(RESOURCE_NOT_FOUND.apply(resourceReference.uri())); + } + if (!uriTemplateManagerFactory.create(resourceTemplate.get().uriTemplate()) + .getVariableNames() + .contains(argumentName)) { + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Argument not found: " + argumentName + " in resource template: " + + resourceReference.uri()) + .build()); + } + return Mono.just(Optional.empty()); + }); + }); + } - if (specification == null) { - return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) - .message("AsyncCompletionSpecification not found: " + request.ref()) - .build()); - } + private Mono> resolvePrompt(String name, McpTransportContext transportContext) { - return specification.completionHandler().apply(ctx, request); - }; + if (this.promptsRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.promptsRepository.resolvePrompt(name, transportContext)); + } + + return Mono.just(Optional.ofNullable(this.prompts.get(name)) + .map(McpStatelessServerFeatures.AsyncPromptSpecification::prompt)); + } + + private Mono> resolveResource(String uri, McpTransportContext transportContext) { + + if (this.resourcesRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.resourcesRepository.resolveResource(uri, transportContext)); + } + + return Mono + .just(findResourceSpecification(uri).map(McpStatelessServerFeatures.AsyncResourceSpecification::resource)); + } + + private Mono> resolveResourceTemplate(String uri, + McpTransportContext transportContext) { + + if (this.resourcesRepository != null) { + return this.repositoryCallAdapter + .invoke(() -> this.resourcesRepository.resolveResourceTemplate(uri, transportContext)); + } + + return Mono.just(findResourceTemplateSpecification(uri) + .map(McpStatelessServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate)); + } + + private Mono resolveCompletionSpecification( + McpSchema.CompleteReference reference) { + + McpStatelessServerFeatures.AsyncCompletionSpecification specification = this.completions.get(reference); + if (specification == null) { + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("AsyncCompletionSpecification not found: " + reference) + .build()); + } + return Mono.just(specification); } /** diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java index 0c1fbfba7..002de8361 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2025 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server; @@ -24,6 +24,7 @@ * * @author Dariusz Jędrzejczyk * @author Christian Tzolov + * @author Taewoong Kim */ public class McpStatelessServerFeatures { @@ -36,6 +37,17 @@ public class McpStatelessServerFeatures { * @param resources The map of resource specifications * @param resourceTemplates The map of resource templates * @param prompts The map of prompt specifications + * @param completions The map of completion specifications + * @param toolsRepository The repository used to resolve tools from the request + * context + * @param resourcesRepository The repository used to resolve resources from the + * request context + * @param promptsRepository The repository used to resolve prompts from the request + * context + * @param completionsRepository The repository used to handle completion requests from + * the request context + * @param immediateExecution Whether repository and synchronous specification calls + * should execute immediately instead of being offloaded * @param instructions The server instructions text */ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, @@ -44,7 +56,9 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s Map resourceTemplates, Map prompts, Map completions, - String instructions) { + ToolsRepository toolsRepository, ResourcesRepository resourcesRepository, + PromptsRepository promptsRepository, CompletionsRepository completionsRepository, + boolean immediateExecution, String instructions) { /** * Create an instance and validate the arguments. @@ -54,6 +68,17 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s * @param resources The map of resource specifications * @param resourceTemplates The map of resource templates * @param prompts The map of prompt specifications + * @param completions The map of completion specifications + * @param toolsRepository The repository used to resolve tools from the request + * context + * @param resourcesRepository The repository used to resolve resources from the + * request context + * @param promptsRepository The repository used to resolve prompts from the + * request context + * @param completionsRepository The repository used to handle completion requests + * from the request context + * @param immediateExecution Whether repository and synchronous specification + * calls should execute immediately instead of being offloaded * @param instructions The server instructions text */ Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, @@ -62,33 +87,42 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s Map resourceTemplates, Map prompts, Map completions, - String instructions) { + ToolsRepository toolsRepository, ResourcesRepository resourcesRepository, + PromptsRepository promptsRepository, CompletionsRepository completionsRepository, + boolean immediateExecution, String instructions) { Assert.notNull(serverInfo, "Server info must not be null"); this.serverInfo = serverInfo; this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities - : new McpSchema.ServerCapabilities(null, // completions - null, // experimental - null, // currently statless server doesn't support set logging - !Utils.isEmpty(prompts) ? McpSchema.ServerCapabilities.PromptCapabilities.builder().build() + : new McpSchema.ServerCapabilities( + (completionsRepository != null) ? new McpSchema.ServerCapabilities.CompletionCapabilities() : null, - !Utils.isEmpty(resources) + null, // experimental + null, // currently stateless server does not support logging + (!Utils.isEmpty(prompts) || promptsRepository != null) + ? McpSchema.ServerCapabilities.PromptCapabilities.builder().build() : null, + (!Utils.isEmpty(resources) || resourcesRepository != null) ? McpSchema.ServerCapabilities.ResourceCapabilities.builder().build() : null, - !Utils.isEmpty(tools) ? McpSchema.ServerCapabilities.ToolCapabilities.builder().build() - : null); + (!Utils.isEmpty(tools) || toolsRepository != null) + ? McpSchema.ServerCapabilities.ToolCapabilities.builder().build() : null); this.tools = (tools != null) ? tools : List.of(); this.resources = (resources != null) ? resources : Map.of(); this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : Map.of(); this.prompts = (prompts != null) ? prompts : Map.of(); this.completions = (completions != null) ? completions : Map.of(); + this.toolsRepository = toolsRepository; + this.resourcesRepository = resourcesRepository; + this.promptsRepository = promptsRepository; + this.completionsRepository = completionsRepository; + this.immediateExecution = immediateExecution; this.instructions = instructions; } /** - * Convert a synchronous specification into an asynchronous one and provide - * blocking code offloading to prevent accidental blocking of the non-blocking + * Convert a synchronous specification into an asynchronous one, optionally + * offloading blocking code to prevent accidental blocking of the non-blocking * transport. * @param syncSpec a potentially blocking, synchronous specification. * @param immediateExecution when true, do not offload. Do NOT set to true when @@ -123,7 +157,9 @@ static Async fromSync(Sync syncSpec, boolean immediateExecution) { }); return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, - prompts, completions, syncSpec.instructions()); + prompts, completions, syncSpec.toolsRepository(), syncSpec.resourcesRepository(), + syncSpec.promptsRepository(), syncSpec.completionsRepository(), immediateExecution, + syncSpec.instructions()); } } @@ -136,6 +172,15 @@ static Async fromSync(Sync syncSpec, boolean immediateExecution) { * @param resources The map of resource specifications * @param resourceTemplates The map of resource templates * @param prompts The map of prompt specifications + * @param completions The map of completion specifications + * @param toolsRepository The repository used to resolve tools from the request + * context + * @param resourcesRepository The repository used to resolve resources from the + * request context + * @param promptsRepository The repository used to resolve prompts from the request + * context + * @param completionsRepository The repository used to handle completion requests from + * the request context * @param instructions The server instructions text */ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, @@ -144,7 +189,8 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se Map resourceTemplates, Map prompts, Map completions, - String instructions) { + ToolsRepository toolsRepository, ResourcesRepository resourcesRepository, + PromptsRepository promptsRepository, CompletionsRepository completionsRepository, String instructions) { /** * Create an instance and validate the arguments. @@ -154,6 +200,15 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se * @param resources The map of resource specifications * @param resourceTemplates The map of resource templates * @param prompts The map of prompt specifications + * @param completions The map of completion specifications + * @param toolsRepository The repository used to resolve tools from the request + * context + * @param resourcesRepository The repository used to resolve resources from the + * request context + * @param promptsRepository The repository used to resolve prompts from the + * request context + * @param completionsRepository The repository used to handle completion requests + * from the request context * @param instructions The server instructions text */ Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, @@ -162,30 +217,37 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se Map resourceTemplates, Map prompts, Map completions, - String instructions) { + ToolsRepository toolsRepository, ResourcesRepository resourcesRepository, + PromptsRepository promptsRepository, CompletionsRepository completionsRepository, String instructions) { Assert.notNull(serverInfo, "Server info must not be null"); this.serverInfo = serverInfo; this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities - : new McpSchema.ServerCapabilities(null, // completions + : new McpSchema.ServerCapabilities( + (completionsRepository != null) ? new McpSchema.ServerCapabilities.CompletionCapabilities() + : null, null, // experimental new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable // logging // by // default - !Utils.isEmpty(prompts) ? McpSchema.ServerCapabilities.PromptCapabilities.builder().build() - : null, - !Utils.isEmpty(resources) + (!Utils.isEmpty(prompts) || promptsRepository != null) + ? McpSchema.ServerCapabilities.PromptCapabilities.builder().build() : null, + (!Utils.isEmpty(resources) || resourcesRepository != null) ? McpSchema.ServerCapabilities.ResourceCapabilities.builder().build() : null, - !Utils.isEmpty(tools) ? McpSchema.ServerCapabilities.ToolCapabilities.builder().build() - : null); + (!Utils.isEmpty(tools) || toolsRepository != null) + ? McpSchema.ServerCapabilities.ToolCapabilities.builder().build() : null); this.tools = (tools != null) ? tools : new ArrayList<>(); this.resources = (resources != null) ? resources : new HashMap<>(); this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : Map.of(); this.prompts = (prompts != null) ? prompts : new HashMap<>(); this.completions = (completions != null) ? completions : new HashMap<>(); + this.toolsRepository = toolsRepository; + this.resourcesRepository = resourcesRepository; + this.promptsRepository = promptsRepository; + this.completionsRepository = completionsRepository; this.instructions = instructions; } @@ -197,8 +259,9 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se * represents a specific capability. * * @param tool The tool definition including name, description, and parameter schema - * @param callHandler The function that implements the tool's logic, receiving a - * {@link CallToolRequest} and returning the result. + * @param callHandler The function that implements the tool's logic. The first + * argument is the {@link McpTransportContext}; the second argument is the + * {@link CallToolRequest}. */ public record AsyncToolSpecification(McpSchema.Tool tool, BiFunction> callHandler) { @@ -290,7 +353,8 @@ public static Builder builder() { * * @param resource The resource definition including name, description, and MIME type * @param readHandler The function that handles resource read requests. The function's - * argument is a {@link McpSchema.ReadResourceRequest}. + * first argument is the {@link McpTransportContext}; the second argument is the + * {@link McpSchema.ReadResourceRequest}. */ public record AsyncResourceSpecification(McpSchema.Resource resource, BiFunction> readHandler) { @@ -308,7 +372,7 @@ static AsyncResourceSpecification fromSync(SyncResourceSpecification resource, b } /** - * Specification of a resource template with its synchronous handler function. + * Specification of a resource template with its asynchronous handler function. * Resource templates allow servers to expose parameterized resources using URI * templates: URI * templates.. Arguments may be auto-completed through > readHandler) { @@ -360,8 +422,8 @@ static AsyncResourceTemplateSpecification fromSync(SyncResourceTemplateSpecifica * * @param prompt The prompt definition including name and description * @param promptHandler The function that processes prompt requests and returns - * formatted templates. The function's argument is a - * {@link McpSchema.GetPromptRequest}. + * formatted templates. The first argument is the {@link McpTransportContext}; the + * second argument is the {@link McpSchema.GetPromptRequest}. */ public record AsyncPromptSpecification(McpSchema.Prompt prompt, BiFunction> promptHandler) { @@ -385,12 +447,13 @@ static AsyncPromptSpecification fromSync(SyncPromptSpecification prompt, boolean *

* * @param referenceKey The unique key representing the completion reference. * @param completionHandler The asynchronous function that processes completion - * requests and returns results. The function's argument is a + * requests and returns results. The first argument is the + * {@link McpTransportContext}; the second argument is the * {@link McpSchema.CompleteRequest}. */ public record AsyncCompletionSpecification(McpSchema.CompleteReference referenceKey, @@ -398,9 +461,10 @@ public record AsyncCompletionSpecification(McpSchema.CompleteReference reference /** * Converts a synchronous {@link SyncCompletionSpecification} into an - * {@link AsyncCompletionSpecification} by wrapping the handler in a bounded - * elastic scheduler for safe non-blocking execution. + * {@link AsyncCompletionSpecification}, optionally offloading the handler to a + * bounded elastic scheduler for safe non-blocking execution. * @param completion the synchronous completion specification + * @param immediateExecution whether the handler should execute immediately * @return an asynchronous wrapper of the provided sync specification, or * {@code null} if input is null */ @@ -422,8 +486,9 @@ static AsyncCompletionSpecification fromSync(SyncCompletionSpecification complet * primary way for MCP servers to expose functionality to AI models. * * @param tool The tool definition including name, description, and parameter schema - * @param callHandler The function that implements the tool's logic, receiving a - * {@link CallToolRequest} and returning results. + * @param callHandler The function that implements the tool's logic. The first + * argument is the {@link McpTransportContext}; the second argument is the + * {@link CallToolRequest}. */ public record SyncToolSpecification(McpSchema.Tool tool, BiFunction callHandler) { @@ -491,7 +556,8 @@ public SyncToolSpecification build() { * * @param resource The resource definition including name, description, and MIME type * @param readHandler The function that handles resource read requests. The function's - * argument is a {@link McpSchema.ReadResourceRequest}. + * first argument is the {@link McpTransportContext}; the second argument is the + * {@link McpSchema.ReadResourceRequest}. */ public record SyncResourceSpecification(McpSchema.Resource resource, BiFunction readHandler) { @@ -516,10 +582,8 @@ public record SyncResourceSpecification(McpSchema.Resource resource, * @param resourceTemplate The resource template definition including name, * description, and parameter schema * @param readHandler The function that handles resource read requests. The function's - * first argument is an {@link McpTransportContext} upon which the server can interact - * with the connected client. The second arguments is a - * {@link McpSchema.ReadResourceRequest}. {@link McpSchema.ResourceTemplate} - * {@link McpSchema.ReadResourceResult} + * first argument is the {@link McpTransportContext}; the second argument is the + * {@link McpSchema.ReadResourceRequest}. */ public record SyncResourceTemplateSpecification(McpSchema.ResourceTemplate resourceTemplate, BiFunction readHandler) { @@ -538,8 +602,8 @@ public record SyncResourceTemplateSpecification(McpSchema.ResourceTemplate resou * * @param prompt The prompt definition including name and description * @param promptHandler The function that processes prompt requests and returns - * formatted templates. The function's argument is a - * {@link McpSchema.GetPromptRequest}. + * formatted templates. The first argument is the {@link McpTransportContext}; the + * second argument is the {@link McpSchema.GetPromptRequest}. */ public record SyncPromptSpecification(McpSchema.Prompt prompt, BiFunction promptHandler) { @@ -550,7 +614,9 @@ public record SyncPromptSpecification(McpSchema.Prompt prompt, * * @param referenceKey The unique key representing the completion reference. * @param completionHandler The synchronous function that processes completion - * requests and returns results. The argument is a {@link McpSchema.CompleteRequest}. + * requests and returns results. The first argument is the + * {@link McpTransportContext}; the second argument is the + * {@link McpSchema.CompleteRequest}. */ public record SyncCompletionSpecification(McpSchema.CompleteReference referenceKey, BiFunction completionHandler) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java index 475f88df8..9fd55a1d9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java @@ -1,15 +1,14 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server; +import java.util.List; + import io.modelcontextprotocol.spec.McpSchema; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; - -import java.util.List; /** * A stateless MCP server implementation for use with Streamable HTTP transport types. It @@ -18,6 +17,7 @@ * knowledge and can serve the clients with the capabilities it supports. * * @author Dariusz Jędrzejczyk + * @author Taewoong Kim */ public class McpStatelessSyncServer { @@ -25,11 +25,8 @@ public class McpStatelessSyncServer { private final McpStatelessAsyncServer asyncServer; - private final boolean immediateExecution; - - McpStatelessSyncServer(McpStatelessAsyncServer asyncServer, boolean immediateExecution) { + McpStatelessSyncServer(McpStatelessAsyncServer asyncServer) { this.asyncServer = asyncServer; - this.immediateExecution = immediateExecution; } /** @@ -68,15 +65,12 @@ public void close() { * @param toolSpecification The tool specification to add */ public void addTool(McpStatelessServerFeatures.SyncToolSpecification toolSpecification) { - this.asyncServer - .addTool(McpStatelessServerFeatures.AsyncToolSpecification.fromSync(toolSpecification, - this.immediateExecution)) - .block(); + this.asyncServer.addTool(toolSpecification).block(); } /** - * List all registered tools. - * @return A list of all registered tools + * List tools without a client request context. + * @return A list of context-free visible tools */ public List listTools() { return this.asyncServer.listTools().collectList().block(); @@ -95,15 +89,12 @@ public void removeTool(String toolName) { * @param resourceSpecification The resource handler to add */ public void addResource(McpStatelessServerFeatures.SyncResourceSpecification resourceSpecification) { - this.asyncServer - .addResource(McpStatelessServerFeatures.AsyncResourceSpecification.fromSync(resourceSpecification, - this.immediateExecution)) - .block(); + this.asyncServer.addResource(resourceSpecification).block(); } /** - * List all registered resources. - * @return A list of all registered resources + * List resources without a client request context. + * @return A list of context-free visible resources */ public List listResources() { return this.asyncServer.listResources().collectList().block(); @@ -123,15 +114,12 @@ public void removeResource(String resourceUri) { */ public void addResourceTemplate( McpStatelessServerFeatures.SyncResourceTemplateSpecification resourceTemplateSpecification) { - this.asyncServer - .addResourceTemplate(McpStatelessServerFeatures.AsyncResourceTemplateSpecification - .fromSync(resourceTemplateSpecification, this.immediateExecution)) - .block(); + this.asyncServer.addResourceTemplate(resourceTemplateSpecification).block(); } /** - * List all registered resource templates. - * @return A list of all registered resource templates + * List resource templates without a client request context. + * @return A list of context-free visible resource templates */ public List listResourceTemplates() { return this.asyncServer.listResourceTemplates().collectList().block(); @@ -150,15 +138,12 @@ public void removeResourceTemplate(String uriTemplate) { * @param promptSpecification The prompt handler to add */ public void addPrompt(McpStatelessServerFeatures.SyncPromptSpecification promptSpecification) { - this.asyncServer - .addPrompt(McpStatelessServerFeatures.AsyncPromptSpecification.fromSync(promptSpecification, - this.immediateExecution)) - .block(); + this.asyncServer.addPrompt(promptSpecification).block(); } /** - * List all registered prompts. - * @return A list of all registered prompts + * List prompts without a client request context. + * @return A list of context-free visible prompts */ public List listPrompts() { return this.asyncServer.listPrompts().collectList().block(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/PromptsRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/PromptsRepository.java new file mode 100644 index 000000000..92da526fb --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/PromptsRepository.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.util.Optional; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Repository contract for listing, resolving, and getting stateless prompts from the + * current MCP request context. + *

+ * Context-free server-side helper calls receive {@link McpTransportContext#EMPTY}. + * + * @author Taewoong Kim + */ +public interface PromptsRepository { + + /** + * List prompts visible for the current request context. + * @param request the paginated list request + * @param transportContext the transport context for the current request, or + * {@link McpTransportContext#EMPTY} for a context-free server-side call + * @return the prompts visible for the current request + */ + McpSchema.ListPromptsResult listPrompts(McpSchema.PaginatedRequest request, McpTransportContext transportContext); + + /** + * Resolve prompt metadata for the current request context. + * @param name the prompt name + * @param transportContext the transport context for the current request + * @return the matching prompt metadata, if any + */ + Optional resolvePrompt(String name, McpTransportContext transportContext); + + /** + * Get a prompt for the current request context. + * @param request the prompt get request + * @param transportContext the transport context for the current request + * @return the prompt result + */ + McpSchema.GetPromptResult getPrompt(McpSchema.GetPromptRequest request, McpTransportContext transportContext); + + /** + * Add a prompt to repositories that support runtime mutation. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + * @param promptSpecification the prompt specification to add + */ + default void addPrompt(McpStatelessServerFeatures.SyncPromptSpecification promptSpecification) { + throw new UnsupportedOperationException("Prompts repository does not support adding prompts"); + } + + /** + * Remove a prompt from repositories that support runtime mutation. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + * @param promptName the prompt name to remove + * @return {@code true} if a prompt was removed + */ + default boolean removePrompt(String promptName) { + throw new UnsupportedOperationException("Prompts repository does not support removing prompts"); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/ResourcesRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/ResourcesRepository.java new file mode 100644 index 000000000..ae4a53ac9 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/ResourcesRepository.java @@ -0,0 +1,110 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.util.Optional; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Repository contract for listing, resolving, and reading stateless resources from the + * current MCP request context. + *

+ * Context-free server-side helper calls receive {@link McpTransportContext#EMPTY}. + * + * @author Taewoong Kim + */ +public interface ResourcesRepository { + + /** + * List resources visible for the current request context. + * @param request the paginated list request + * @param transportContext the transport context for the current request, or + * {@link McpTransportContext#EMPTY} for a context-free server-side call + * @return the resources visible for the current request + */ + McpSchema.ListResourcesResult listResources(McpSchema.PaginatedRequest request, + McpTransportContext transportContext); + + /** + * List resource templates visible for the current request context. + * @param request the paginated list request + * @param transportContext the transport context for the current request, or + * {@link McpTransportContext#EMPTY} for a context-free server-side call + * @return the resource templates visible for the current request + */ + McpSchema.ListResourceTemplatesResult listResourceTemplates(McpSchema.PaginatedRequest request, + McpTransportContext transportContext); + + /** + * Resolve resource metadata for the current request context. + * @param uri the requested resource URI + * @param transportContext the transport context for the current request + * @return the matching resource metadata, if any + */ + Optional resolveResource(String uri, McpTransportContext transportContext); + + /** + * Resolve resource-template metadata for the current request context. + * @param uri the requested resource URI + * @param transportContext the transport context for the current request + * @return the matching resource-template metadata, if any + */ + Optional resolveResourceTemplate(String uri, McpTransportContext transportContext); + + /** + * Read a resource for the current request context. + * @param request the resource read request + * @param transportContext the transport context for the current request + * @return the resource read result + */ + McpSchema.ReadResourceResult readResource(McpSchema.ReadResourceRequest request, + McpTransportContext transportContext); + + /** + * Add a resource to repositories that support runtime mutation. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + * @param resourceSpecification the resource specification to add + */ + default void addResource(McpStatelessServerFeatures.SyncResourceSpecification resourceSpecification) { + throw new UnsupportedOperationException("Resources repository does not support adding resources"); + } + + /** + * Remove a resource from repositories that support runtime mutation. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + * @param resourceUri the resource URI to remove + * @return {@code true} if a resource was removed + */ + default boolean removeResource(String resourceUri) { + throw new UnsupportedOperationException("Resources repository does not support removing resources"); + } + + /** + * Add a resource template to repositories that support runtime mutation. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + * @param resourceTemplateSpecification the resource template specification to add + */ + default void addResourceTemplate( + McpStatelessServerFeatures.SyncResourceTemplateSpecification resourceTemplateSpecification) { + throw new UnsupportedOperationException("Resources repository does not support adding resource templates"); + } + + /** + * Remove a resource template from repositories that support runtime mutation. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + * @param uriTemplate the resource template URI template to remove + * @return {@code true} if a resource template was removed + */ + default boolean removeResourceTemplate(String uriTemplate) { + throw new UnsupportedOperationException("Resources repository does not support removing resource templates"); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/SyncRepositoryCallAdapter.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/SyncRepositoryCallAdapter.java new file mode 100644 index 000000000..9c015be75 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/SyncRepositoryCallAdapter.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Adapts synchronous repository calls to the stateless asynchronous server core. + * + * @author Taewoong Kim + */ +final class SyncRepositoryCallAdapter { + + private final boolean immediateExecution; + + SyncRepositoryCallAdapter(boolean immediateExecution) { + this.immediateExecution = immediateExecution; + } + + Mono invoke(Supplier supplier) { + Mono result = Mono.fromCallable(supplier::get); + return this.immediateExecution ? result : result.subscribeOn(Schedulers.boundedElastic()); + } + + Mono run(Runnable runnable) { + Mono result = Mono.fromRunnable(runnable); + return this.immediateExecution ? result : result.subscribeOn(Schedulers.boundedElastic()); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/ToolsRepository.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/ToolsRepository.java new file mode 100644 index 000000000..7d0a83cdd --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/ToolsRepository.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.util.Optional; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; + +/** + * Repository contract for listing, resolving, and calling stateless tools from the + * current MCP request context. + *

+ * Context-free server-side helper calls receive {@link McpTransportContext#EMPTY}. + * + * @author Taewoong Kim + */ +public interface ToolsRepository { + + /** + * List tools visible for the current request context. + * @param request the paginated list request + * @param transportContext the transport context for the current request, or + * {@link McpTransportContext#EMPTY} for a context-free server-side call + * @return the tools visible for the current request + */ + McpSchema.ListToolsResult listTools(McpSchema.PaginatedRequest request, McpTransportContext transportContext); + + /** + * Resolve tool metadata for the current request context. + * @param name the tool name + * @param transportContext the transport context for the current request + * @return the matching tool metadata, if any + */ + Optional resolveTool(String name, McpTransportContext transportContext); + + /** + * Call a tool for the current request context. + * @param request the tool call request + * @param transportContext the transport context for the current request + * @return the tool call result + */ + CallToolResult callTool(McpSchema.CallToolRequest request, McpTransportContext transportContext); + + /** + * Add a tool to repositories that support runtime mutation. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + * @param toolSpecification the tool specification to add + */ + default void addTool(McpStatelessServerFeatures.SyncToolSpecification toolSpecification) { + throw new UnsupportedOperationException("Tools repository does not support adding tools"); + } + + /** + * Remove a tool from repositories that support runtime mutation. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + * @param toolName the tool name to remove + * @return {@code true} if a tool was removed + */ + default boolean removeTool(String toolName) { + throw new UnsupportedOperationException("Tools repository does not support removing tools"); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/StatelessRepositoriesTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/StatelessRepositoriesTests.java new file mode 100644 index 000000000..5bf756b63 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/StatelessRepositoriesTests.java @@ -0,0 +1,746 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpStatelessServerTransport; +import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for stateless primitive repositories. + * + * @author Taewoong Kim + */ +class StatelessRepositoriesTests { + + private static final String USER_KEY = "X-User"; + + private static final JsonSchemaValidator VALID_SCHEMA_VALIDATOR = (schema, structuredContent) -> ValidationResponse + .asValid(null); + + private static final McpJsonMapper JSON_MAPPER = new PassThroughMcpJsonMapper(); + + @Test + void statelessRepositoriesResolvePrimitivesFromTransportContext() { + var transport = new MockStatelessServerTransport(); + var repository = new ContextAwareRepository(); + + McpServer.sync(transport) + .jsonMapper(JSON_MAPPER) + .jsonSchemaValidator(VALID_SCHEMA_VALIDATOR) + .toolsRepository(repository) + .resourcesRepository(repository) + .promptsRepository(repository) + .completionsRepository(repository) + .build(); + + McpSchema.ListToolsResult tools = result(transport, "alice", McpSchema.METHOD_TOOLS_LIST, + new McpSchema.PaginatedRequest()); + assertThat(tools.tools()).extracting(McpSchema.Tool::name).containsExactly("alice-tool"); + + McpSchema.CallToolResult toolResult = result(transport, "alice", McpSchema.METHOD_TOOLS_CALL, + McpSchema.CallToolRequest.builder("alice-tool").build()); + assertThat(text(toolResult.content().get(0))).isEqualTo("tool:alice"); + + McpSchema.ListResourcesResult resources = result(transport, "alice", McpSchema.METHOD_RESOURCES_LIST, + new McpSchema.PaginatedRequest()); + assertThat(resources.resources()).extracting(McpSchema.Resource::uri).containsExactly("test://resources/alice"); + + McpSchema.ReadResourceResult resourceResult = result(transport, "alice", McpSchema.METHOD_RESOURCES_READ, + McpSchema.ReadResourceRequest.builder("test://resources/alice").build()); + assertThat(((McpSchema.TextResourceContents) resourceResult.contents().get(0)).text()) + .isEqualTo("resource:alice"); + + McpSchema.ListResourceTemplatesResult templates = result(transport, "alice", + McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest()); + assertThat(templates.resourceTemplates()).extracting(McpSchema.ResourceTemplate::uriTemplate) + .containsExactly("test://resource-templates/alice/{name}"); + + McpSchema.ReadResourceResult templateResult = result(transport, "alice", McpSchema.METHOD_RESOURCES_READ, + McpSchema.ReadResourceRequest.builder("test://resource-templates/alice/guide").build()); + assertThat(((McpSchema.TextResourceContents) templateResult.contents().get(0)).text()) + .isEqualTo("resource-template:alice"); + + McpSchema.ListPromptsResult prompts = result(transport, "alice", McpSchema.METHOD_PROMPT_LIST, + new McpSchema.PaginatedRequest()); + assertThat(prompts.prompts()).extracting(McpSchema.Prompt::name).containsExactly("alice-prompt"); + + McpSchema.GetPromptResult promptResult = result(transport, "alice", McpSchema.METHOD_PROMPT_GET, + McpSchema.GetPromptRequest.builder("alice-prompt").build()); + assertThat(text(promptResult.messages().get(0).content())).isEqualTo("prompt:alice"); + + McpSchema.CompleteResult completeResult = result(transport, "alice", McpSchema.METHOD_COMPLETION_COMPLETE, + McpSchema.CompleteRequest + .builder(new McpSchema.PromptReference("alice-prompt"), + new McpSchema.CompleteRequest.CompleteArgument("name", "a")) + .build()); + assertThat(completeResult.completion().values()).containsExactly("completion:alice"); + + McpSchema.CompleteResult resourceCompleteResult = result(transport, "alice", + McpSchema.METHOD_COMPLETION_COMPLETE, + McpSchema.CompleteRequest + .builder(new McpSchema.ResourceReference("test://resource-templates/alice/{name}"), + new McpSchema.CompleteRequest.CompleteArgument("name", "g")) + .build()); + assertThat(resourceCompleteResult.completion().values()).containsExactly("completion:alice"); + + McpSchema.ListToolsResult bobTools = result(transport, "bob", McpSchema.METHOD_TOOLS_LIST, + new McpSchema.PaginatedRequest()); + assertThat(bobTools.tools()).extracting(McpSchema.Tool::name).containsExactly("bob-tool"); + + McpSchema.CallToolResult bobToolResult = result(transport, "bob", McpSchema.METHOD_TOOLS_CALL, + McpSchema.CallToolRequest.builder("bob-tool").build()); + assertThat(text(bobToolResult.content().get(0))).isEqualTo("tool:bob"); + + McpSchema.JSONRPCResponse hiddenResource = response(transport, "bob", McpSchema.METHOD_RESOURCES_READ, + McpSchema.ReadResourceRequest.builder("test://resources/alice").build()); + assertThat(hiddenResource.error()).isNotNull(); + assertThat(hiddenResource.error().code()).isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND); + } + + @Test + void statelessRepositoriesReceivePaginatedListRequests() { + var transport = new MockStatelessServerTransport(); + var repository = new ContextAwareRepository(); + + McpServer.sync(transport) + .jsonMapper(JSON_MAPPER) + .jsonSchemaValidator(VALID_SCHEMA_VALIDATOR) + .toolsRepository(repository) + .resourcesRepository(repository) + .promptsRepository(repository) + .build(); + + McpSchema.ListToolsResult tools = result(transport, "alice", McpSchema.METHOD_TOOLS_LIST, + new McpSchema.PaginatedRequest("tools-cursor")); + assertThat(tools.nextCursor()).isEqualTo("tools-cursor"); + + McpSchema.ListResourcesResult resources = result(transport, "alice", McpSchema.METHOD_RESOURCES_LIST, + new McpSchema.PaginatedRequest("resources-cursor")); + assertThat(resources.nextCursor()).isEqualTo("resources-cursor"); + + McpSchema.ListResourceTemplatesResult templates = result(transport, "alice", + McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest("templates-cursor")); + assertThat(templates.nextCursor()).isEqualTo("templates-cursor"); + + McpSchema.ListPromptsResult prompts = result(transport, "alice", McpSchema.METHOD_PROMPT_LIST, + new McpSchema.PaginatedRequest("prompts-cursor")); + assertThat(prompts.nextCursor()).isEqualTo("prompts-cursor"); + } + + @Test + void statelessRepositorySupportsRuntimeToolMutation() { + var transport = new MockStatelessServerTransport(); + var repository = new MutableRepository(); + + McpStatelessSyncServer server = McpServer.sync(transport) + .jsonMapper(JSON_MAPPER) + .jsonSchemaValidator(VALID_SCHEMA_VALIDATOR) + .toolsRepository(repository) + .build(); + + server.addTool(toolSpec("runtime-tool", "runtime")); + + McpSchema.ListToolsResult tools = result(transport, "alice", McpSchema.METHOD_TOOLS_LIST, + new McpSchema.PaginatedRequest()); + assertThat(tools.tools()).extracting(McpSchema.Tool::name).containsExactly("runtime-tool"); + + server.removeTool("runtime-tool"); + + McpSchema.ListToolsResult emptyTools = result(transport, "alice", McpSchema.METHOD_TOOLS_LIST, + new McpSchema.PaginatedRequest()); + assertThat(emptyTools.tools()).isEmpty(); + } + + @Test + void statelessRepositoriesSupportRuntimeResourceAndPromptMutation() { + var transport = new MockStatelessServerTransport(); + var repository = new MutableRepository(); + + McpStatelessSyncServer server = McpServer.sync(transport) + .jsonMapper(JSON_MAPPER) + .jsonSchemaValidator(VALID_SCHEMA_VALIDATOR) + .resourcesRepository(repository) + .promptsRepository(repository) + .build(); + + server.addResource(resourceSpec("test://resources/runtime", "runtime-resource", "resource-runtime")); + server.addResourceTemplate(resourceTemplateSpec("test://resource-templates/runtime/{name}", + "runtime-resource-template", "template-runtime")); + server.addPrompt(promptSpec("runtime-prompt", "prompt-runtime")); + + McpSchema.ListResourcesResult resources = result(transport, "alice", McpSchema.METHOD_RESOURCES_LIST, + new McpSchema.PaginatedRequest()); + assertThat(resources.resources()).extracting(McpSchema.Resource::uri) + .containsExactly("test://resources/runtime"); + McpSchema.ListResourceTemplatesResult templates = result(transport, "alice", + McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest()); + assertThat(templates.resourceTemplates()).extracting(McpSchema.ResourceTemplate::uriTemplate) + .containsExactly("test://resource-templates/runtime/{name}"); + McpSchema.ListPromptsResult prompts = result(transport, "alice", McpSchema.METHOD_PROMPT_LIST, + new McpSchema.PaginatedRequest()); + assertThat(prompts.prompts()).extracting(McpSchema.Prompt::name).containsExactly("runtime-prompt"); + + McpSchema.ReadResourceResult resourceResult = result(transport, "alice", McpSchema.METHOD_RESOURCES_READ, + McpSchema.ReadResourceRequest.builder("test://resources/runtime").build()); + assertThat(((McpSchema.TextResourceContents) resourceResult.contents().get(0)).text()) + .isEqualTo("resource-runtime"); + McpSchema.ReadResourceResult templateResult = result(transport, "alice", McpSchema.METHOD_RESOURCES_READ, + McpSchema.ReadResourceRequest.builder("test://resource-templates/runtime/guide").build()); + assertThat(((McpSchema.TextResourceContents) templateResult.contents().get(0)).text()) + .isEqualTo("template-runtime"); + McpSchema.GetPromptResult promptResult = result(transport, "alice", McpSchema.METHOD_PROMPT_GET, + McpSchema.GetPromptRequest.builder("runtime-prompt").build()); + assertThat(text(promptResult.messages().get(0).content())).isEqualTo("prompt-runtime"); + + server.removeResource("test://resources/runtime"); + server.removeResourceTemplate("test://resource-templates/runtime/{name}"); + server.removePrompt("runtime-prompt"); + + McpSchema.ListResourcesResult emptyResources = result(transport, "alice", McpSchema.METHOD_RESOURCES_LIST, + new McpSchema.PaginatedRequest()); + assertThat(emptyResources.resources()).isEmpty(); + McpSchema.ListResourceTemplatesResult emptyTemplates = result(transport, "alice", + McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest()); + assertThat(emptyTemplates.resourceTemplates()).isEmpty(); + McpSchema.ListPromptsResult emptyPrompts = result(transport, "alice", McpSchema.METHOD_PROMPT_LIST, + new McpSchema.PaginatedRequest()); + assertThat(emptyPrompts.prompts()).isEmpty(); + } + + @Test + void statelessRuntimeMutationRequiresRepositoryMutationSupport() { + var transport = new MockStatelessServerTransport(); + var repository = new ContextAwareRepository(); + + McpStatelessSyncServer server = McpServer.sync(transport) + .jsonMapper(JSON_MAPPER) + .jsonSchemaValidator(VALID_SCHEMA_VALIDATOR) + .toolsRepository(repository) + .resourcesRepository(repository) + .promptsRepository(repository) + .build(); + + assertThatThrownBy(() -> server.addTool(toolSpec("runtime-tool", "runtime"))) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support adding tools"); + assertThatThrownBy(() -> server.removeTool("runtime-tool")).isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support removing tools"); + + assertThatThrownBy(() -> server + .addResource(resourceSpec("test://resources/runtime", "runtime-resource", "resource-runtime"))) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support adding resources"); + assertThatThrownBy(() -> server.removeResource("test://resources/runtime")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support removing resources"); + + assertThatThrownBy( + () -> server.addResourceTemplate(resourceTemplateSpec("test://resource-templates/runtime/{name}", + "runtime-resource-template", "template-runtime"))) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support adding resource templates"); + assertThatThrownBy(() -> server.removeResourceTemplate("test://resource-templates/runtime/{name}")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support removing resource templates"); + + assertThatThrownBy(() -> server.addPrompt(promptSpec("runtime-prompt", "prompt-runtime"))) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support adding prompts"); + assertThatThrownBy(() -> server.removePrompt("runtime-prompt")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support removing prompts"); + } + + @Test + void statelessBuilderRejectsRepositoriesMixedWithStaticRegistrations() { + var tool = tool("static-tool"); + var prompt = McpSchema.Prompt.builder("static-prompt").build(); + var resource = McpSchema.Resource.builder("test://resources/static", "static-resource").build(); + var reference = new McpSchema.PromptReference("static-prompt"); + + assertThatThrownBy(() -> McpServer.sync(new MockStatelessServerTransport()) + .jsonSchemaValidator(VALID_SCHEMA_VALIDATOR) + .toolsRepository(new MutableRepository()) + .toolCall(tool, (context, request) -> McpSchema.CallToolResult.builder().build())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("toolsRepository"); + assertThatThrownBy(() -> McpServer.sync(new MockStatelessServerTransport()) + .jsonSchemaValidator(VALID_SCHEMA_VALIDATOR) + .toolCall(tool, (context, request) -> McpSchema.CallToolResult.builder().build()) + .toolsRepository(new MutableRepository())).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("toolsRepository"); + + assertThatThrownBy(() -> McpServer.sync(new MockStatelessServerTransport()) + .resourcesRepository(new MutableRepository()) + .resources(new McpStatelessServerFeatures.SyncResourceSpecification(resource, + (context, request) -> McpSchema.ReadResourceResult.builder(List.of()).build()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("resourcesRepository"); + assertThatThrownBy(() -> McpServer.sync(new MockStatelessServerTransport()) + .resources(new McpStatelessServerFeatures.SyncResourceSpecification(resource, + (context, request) -> McpSchema.ReadResourceResult.builder(List.of()).build())) + .resourcesRepository(new MutableRepository())).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("resourcesRepository"); + + assertThatThrownBy(() -> McpServer.sync(new MockStatelessServerTransport()) + .promptsRepository(new MutableRepository()) + .prompts(new McpStatelessServerFeatures.SyncPromptSpecification(prompt, + (context, request) -> McpSchema.GetPromptResult.builder(List.of()).build()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("promptsRepository"); + assertThatThrownBy(() -> McpServer.sync(new MockStatelessServerTransport()) + .prompts(new McpStatelessServerFeatures.SyncPromptSpecification(prompt, + (context, request) -> McpSchema.GetPromptResult.builder(List.of()).build())) + .promptsRepository(new MutableRepository())).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("promptsRepository"); + + assertThatThrownBy(() -> McpServer.sync(new MockStatelessServerTransport()) + .completionsRepository(new MutableRepository()) + .completions(new McpStatelessServerFeatures.SyncCompletionSpecification(reference, + (context, request) -> new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of()))))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("completionsRepository"); + assertThatThrownBy(() -> McpServer.sync(new MockStatelessServerTransport()) + .completions(new McpStatelessServerFeatures.SyncCompletionSpecification(reference, + (context, request) -> new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of())))) + .completionsRepository(new MutableRepository())).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("completionsRepository"); + } + + @SuppressWarnings("unchecked") + private static T result(MockStatelessServerTransport transport, String user, String method, Object params) { + McpSchema.JSONRPCResponse response = response(transport, user, method, params); + assertThat(response).isNotNull(); + assertThat(response.error()).isNull(); + return (T) response.result(); + } + + private static McpSchema.JSONRPCResponse response(MockStatelessServerTransport transport, String user, + String method, Object params) { + return transport.handler + .handleRequest(McpTransportContext.create(Map.of(USER_KEY, user)), + new McpSchema.JSONRPCRequest(method, method + "-id", params)) + .block(); + } + + private static McpStatelessServerFeatures.SyncToolSpecification toolSpec(String name, String responseText) { + return new McpStatelessServerFeatures.SyncToolSpecification(tool(name), + (context, request) -> McpSchema.CallToolResult.builder().addTextContent(responseText).build()); + } + + private static McpStatelessServerFeatures.SyncResourceSpecification resourceSpec(String uri, String name, + String responseText) { + var resource = McpSchema.Resource.builder(uri, name).build(); + return new McpStatelessServerFeatures.SyncResourceSpecification(resource, + (context, request) -> McpSchema.ReadResourceResult + .builder(List.of(McpSchema.TextResourceContents.builder(request.uri(), responseText).build())) + .build()); + } + + private static McpStatelessServerFeatures.SyncResourceTemplateSpecification resourceTemplateSpec(String uriTemplate, + String name, String responseText) { + var resourceTemplate = McpSchema.ResourceTemplate.builder(uriTemplate, name).build(); + return new McpStatelessServerFeatures.SyncResourceTemplateSpecification(resourceTemplate, + (context, request) -> McpSchema.ReadResourceResult + .builder(List.of(McpSchema.TextResourceContents.builder(request.uri(), responseText).build())) + .build()); + } + + private static McpStatelessServerFeatures.SyncPromptSpecification promptSpec(String name, String responseText) { + var prompt = McpSchema.Prompt.builder(name).build(); + return new McpStatelessServerFeatures.SyncPromptSpecification(prompt, + (context, + request) -> McpSchema.GetPromptResult.builder(List.of(McpSchema.PromptMessage + .builder(McpSchema.Role.USER, McpSchema.TextContent.builder(responseText).build()) + .build())).build()); + } + + private static McpSchema.Tool tool(String name) { + return McpSchema.Tool.builder(name, Map.of("type", "object")).build(); + } + + private static String text(McpSchema.Content content) { + return ((McpSchema.TextContent) content).text(); + } + + private static String user(McpTransportContext transportContext) { + Object user = transportContext.get(USER_KEY); + return (user != null) ? user.toString() : "anonymous"; + } + + private static final class PassThroughMcpJsonMapper implements McpJsonMapper { + + private final GsonMcpJsonMapper delegate = new GsonMcpJsonMapper(); + + @Override + public T readValue(String content, Class type) throws IOException { + return this.delegate.readValue(content, type); + } + + @Override + public T readValue(byte[] content, Class type) throws IOException { + return this.delegate.readValue(content, type); + } + + @Override + public T readValue(String content, TypeRef type) throws IOException { + return this.delegate.readValue(content, type); + } + + @Override + public T readValue(byte[] content, TypeRef type) throws IOException { + return this.delegate.readValue(content, type); + } + + @Override + public T convertValue(Object fromValue, Class type) { + if (type.isInstance(fromValue)) { + return type.cast(fromValue); + } + return this.delegate.convertValue(fromValue, type); + } + + @SuppressWarnings("unchecked") + @Override + public T convertValue(Object fromValue, TypeRef type) { + Type targetType = type.getType(); + if (targetType instanceof Class targetClass && targetClass.isInstance(fromValue)) { + return (T) fromValue; + } + return this.delegate.convertValue(fromValue, type); + } + + @Override + public String writeValueAsString(Object value) throws IOException { + return this.delegate.writeValueAsString(value); + } + + @Override + public byte[] writeValueAsBytes(Object value) throws IOException { + return this.delegate.writeValueAsBytes(value); + } + + } + + private static final class MockStatelessServerTransport implements McpStatelessServerTransport { + + private McpStatelessServerHandler handler; + + @Override + public void setMcpHandler(McpStatelessServerHandler mcpHandler) { + this.handler = mcpHandler; + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + } + + private static final class ContextAwareRepository + implements ToolsRepository, ResourcesRepository, PromptsRepository, CompletionsRepository { + + @Override + public McpSchema.ListToolsResult listTools(McpSchema.PaginatedRequest request, + McpTransportContext transportContext) { + return McpSchema.ListToolsResult.builder(List.of(tool(user(transportContext) + "-tool"))) + .nextCursor(request.cursor()) + .build(); + } + + @Override + public Optional resolveTool(String name, McpTransportContext transportContext) { + String user = user(transportContext); + if (!name.equals(user + "-tool")) { + return Optional.empty(); + } + return Optional.of(tool(name)); + } + + @Override + public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest request, + McpTransportContext transportContext) { + return McpSchema.CallToolResult.builder().addTextContent("tool:" + user(transportContext)).build(); + } + + @Override + public McpSchema.ListResourcesResult listResources(McpSchema.PaginatedRequest request, + McpTransportContext transportContext) { + String user = user(transportContext); + return McpSchema.ListResourcesResult + .builder(List.of(McpSchema.Resource.builder("test://resources/" + user, user + "-resource").build())) + .nextCursor(request.cursor()) + .build(); + } + + @Override + public McpSchema.ListResourceTemplatesResult listResourceTemplates(McpSchema.PaginatedRequest request, + McpTransportContext transportContext) { + String user = user(transportContext); + return McpSchema.ListResourceTemplatesResult.builder(List.of(McpSchema.ResourceTemplate + .builder("test://resource-templates/" + user + "/{name}", user + "-resource-template") + .build())).nextCursor(request.cursor()).build(); + } + + @Override + public Optional resolveResource(String uri, McpTransportContext transportContext) { + String user = user(transportContext); + if (!uri.equals("test://resources/" + user)) { + return Optional.empty(); + } + return Optional.of(McpSchema.Resource.builder(uri, user + "-resource").build()); + } + + @Override + public Optional resolveResourceTemplate(String uri, + McpTransportContext transportContext) { + String user = user(transportContext); + if (!uri.equals("test://resource-templates/" + user + "/{name}") + && !uri.equals("test://resource-templates/" + user + "/guide")) { + return Optional.empty(); + } + return Optional.of(McpSchema.ResourceTemplate + .builder("test://resource-templates/" + user + "/{name}", user + "-resource-template") + .build()); + } + + @Override + public McpSchema.ReadResourceResult readResource(McpSchema.ReadResourceRequest request, + McpTransportContext transportContext) { + String user = user(transportContext); + String content = request.uri().equals("test://resources/" + user) ? "resource:" + user + : "resource-template:" + user; + return McpSchema.ReadResourceResult + .builder(List.of(McpSchema.TextResourceContents.builder(request.uri(), content).build())) + .build(); + } + + @Override + public McpSchema.ListPromptsResult listPrompts(McpSchema.PaginatedRequest request, + McpTransportContext transportContext) { + String user = user(transportContext); + return McpSchema.ListPromptsResult.builder(List.of(McpSchema.Prompt.builder(user + "-prompt") + .arguments(List.of(McpSchema.PromptArgument.builder("name").build())) + .build())).nextCursor(request.cursor()).build(); + } + + @Override + public Optional resolvePrompt(String name, McpTransportContext transportContext) { + String user = user(transportContext); + if (!name.equals(user + "-prompt")) { + return Optional.empty(); + } + return Optional.of(McpSchema.Prompt.builder(name) + .arguments(List.of(McpSchema.PromptArgument.builder("name").build())) + .build()); + } + + @Override + public McpSchema.GetPromptResult getPrompt(McpSchema.GetPromptRequest request, + McpTransportContext transportContext) { + return McpSchema.GetPromptResult + .builder( + List.of(McpSchema.PromptMessage + .builder(McpSchema.Role.USER, + McpSchema.TextContent.builder("prompt:" + user(transportContext)).build()) + .build())) + .build(); + } + + @Override + public McpSchema.CompleteResult complete(McpSchema.CompleteRequest request, + McpTransportContext transportContext) { + return new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of("completion:" + user(transportContext)))); + } + + } + + private static final class MutableRepository + implements ToolsRepository, ResourcesRepository, PromptsRepository, CompletionsRepository { + + private final Map tools = new LinkedHashMap<>(); + + private final Map resources = new LinkedHashMap<>(); + + private final Map resourceTemplates = new LinkedHashMap<>(); + + private final Map prompts = new LinkedHashMap<>(); + + @Override + public McpSchema.ListToolsResult listTools(McpSchema.PaginatedRequest request, + McpTransportContext transportContext) { + return McpSchema.ListToolsResult.builder( + this.tools.values().stream().map(McpStatelessServerFeatures.SyncToolSpecification::tool).toList()) + .build(); + } + + @Override + public Optional resolveTool(String name, McpTransportContext transportContext) { + return Optional.ofNullable(this.tools.get(name)) + .map(McpStatelessServerFeatures.SyncToolSpecification::tool); + } + + @Override + public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest request, + McpTransportContext transportContext) { + return this.tools.get(request.name()).callHandler().apply(transportContext, request); + } + + @Override + public void addTool(McpStatelessServerFeatures.SyncToolSpecification toolSpecification) { + this.tools.put(toolSpecification.tool().name(), toolSpecification); + } + + @Override + public boolean removeTool(String toolName) { + return this.tools.remove(toolName) != null; + } + + @Override + public McpSchema.ListResourcesResult listResources(McpSchema.PaginatedRequest request, + McpTransportContext transportContext) { + return McpSchema.ListResourcesResult + .builder(this.resources.values() + .stream() + .map(McpStatelessServerFeatures.SyncResourceSpecification::resource) + .toList()) + .build(); + } + + @Override + public McpSchema.ListResourceTemplatesResult listResourceTemplates(McpSchema.PaginatedRequest request, + McpTransportContext transportContext) { + return McpSchema.ListResourceTemplatesResult + .builder(this.resourceTemplates.values() + .stream() + .map(McpStatelessServerFeatures.SyncResourceTemplateSpecification::resourceTemplate) + .toList()) + .build(); + } + + @Override + public Optional resolveResource(String uri, McpTransportContext transportContext) { + return Optional.ofNullable(this.resources.get(uri)) + .map(McpStatelessServerFeatures.SyncResourceSpecification::resource); + } + + @Override + public Optional resolveResourceTemplate(String uri, + McpTransportContext transportContext) { + return this.resourceTemplates.values() + .stream() + .filter(resourceTemplate -> matches(resourceTemplate.resourceTemplate().uriTemplate(), uri)) + .map(McpStatelessServerFeatures.SyncResourceTemplateSpecification::resourceTemplate) + .findFirst(); + } + + @Override + public McpSchema.ReadResourceResult readResource(McpSchema.ReadResourceRequest request, + McpTransportContext transportContext) { + McpStatelessServerFeatures.SyncResourceSpecification resourceSpecification = this.resources + .get(request.uri()); + if (resourceSpecification != null) { + return resourceSpecification.readHandler().apply(transportContext, request); + } + return this.resourceTemplates.values() + .stream() + .filter(resourceTemplate -> matches(resourceTemplate.resourceTemplate().uriTemplate(), request.uri())) + .findFirst() + .orElseThrow() + .readHandler() + .apply(transportContext, request); + } + + @Override + public void addResource(McpStatelessServerFeatures.SyncResourceSpecification resourceSpecification) { + this.resources.put(resourceSpecification.resource().uri(), resourceSpecification); + } + + @Override + public boolean removeResource(String resourceUri) { + return this.resources.remove(resourceUri) != null; + } + + @Override + public void addResourceTemplate( + McpStatelessServerFeatures.SyncResourceTemplateSpecification resourceTemplateSpecification) { + this.resourceTemplates.put(resourceTemplateSpecification.resourceTemplate().uriTemplate(), + resourceTemplateSpecification); + } + + @Override + public boolean removeResourceTemplate(String uriTemplate) { + return this.resourceTemplates.remove(uriTemplate) != null; + } + + @Override + public McpSchema.ListPromptsResult listPrompts(McpSchema.PaginatedRequest request, + McpTransportContext transportContext) { + return McpSchema.ListPromptsResult + .builder(this.prompts.values() + .stream() + .map(McpStatelessServerFeatures.SyncPromptSpecification::prompt) + .toList()) + .build(); + } + + @Override + public Optional resolvePrompt(String name, McpTransportContext transportContext) { + return Optional.ofNullable(this.prompts.get(name)) + .map(McpStatelessServerFeatures.SyncPromptSpecification::prompt); + } + + @Override + public McpSchema.GetPromptResult getPrompt(McpSchema.GetPromptRequest request, + McpTransportContext transportContext) { + return this.prompts.get(request.name()).promptHandler().apply(transportContext, request); + } + + @Override + public void addPrompt(McpStatelessServerFeatures.SyncPromptSpecification promptSpecification) { + this.prompts.put(promptSpecification.prompt().name(), promptSpecification); + } + + @Override + public boolean removePrompt(String promptName) { + return this.prompts.remove(promptName) != null; + } + + @Override + public McpSchema.CompleteResult complete(McpSchema.CompleteRequest request, + McpTransportContext transportContext) { + return new McpSchema.CompleteResult(new McpSchema.CompleteResult.CompleteCompletion(List.of())); + } + + private static boolean matches(String uriTemplate, String uri) { + String prefix = uriTemplate.replace("{name}", ""); + return uriTemplate.equals(uri) || uri.startsWith(prefix); + } + + } + +}