From e8950226f6550be7856f823507f9ccba63ff1643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20Je=CC=A8drzejczyk?= Date: Mon, 26 Oct 2026 18:01:11 +0100 Subject: [PATCH] Enforce required client capabilities in the modern server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handlers had to check ctx.clientCapabilities() themselves before asking for elicitation, sampling or roots. Nothing stopped one from sending an input request the client never declared. Tools, prompts and resources can now declare the capabilities they cannot work without, via of(..., requiredCapabilities, handler) on McpAsync/SyncTool, McpAsync/SyncPrompt and McpAsync/SyncResource. The feature checks them before the handler runs and answers a client that lacks them with -32021, listing only what is missing. Optional input is still requested only when the client declares it. McpServer now rejects any input request for an undeclared capability as an internal error, checking URL-mode elicitation separately. This covers extension methods too. The javadoc on the handler types, InputRequired, McpRequestContext and McpException describes which strategy to use. The conformance servlet declares its requirements instead of checking them by hand. Signed-off-by: Dariusz Jędrzejczyk --- .../modern/ModernConformanceServlet.java | 56 ++++++---- .../modern/McpException.java | 7 ++ .../modern/McpSchema.java | 8 +- .../modern/server/McpFeature.java | 5 +- .../modern/server/McpRequestContext.java | 6 +- .../modern/server/McpServer.java | 37 +++++++ .../modern/server/feature/McpAsyncPrompt.java | 30 +++++- .../server/feature/McpAsyncResource.java | 30 +++++- .../modern/server/feature/McpAsyncTool.java | 29 ++++- .../modern/server/feature/McpSyncPrompt.java | 23 +++- .../server/feature/McpSyncResource.java | 23 +++- .../modern/server/feature/McpSyncTool.java | 22 +++- .../modern/server/feature/PromptsFeature.java | 6 +- .../server/feature/RequiredCapabilities.java | 64 +++++++++++ .../server/feature/ResourcesFeature.java | 15 ++- .../modern/server/feature/ToolsFeature.java | 6 +- .../modern/server/McpServerMrtrTests.java | 98 ++++++++++++++--- .../modern/server/ModernTestFixtures.java | 14 ++- .../server/feature/PromptsFeatureTests.java | 32 ++++++ .../server/feature/ResourcesFeatureTests.java | 20 ++++ .../server/feature/ToolsFeatureTests.java | 102 ++++++++++++++++++ 21 files changed, 579 insertions(+), 54 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/RequiredCapabilities.java diff --git a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java index fbc593e82..706470c2a 100644 --- a/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java +++ b/conformance-tests/server-servlet-modern/src/main/java/io/modelcontextprotocol/conformance/server/modern/ModernConformanceServlet.java @@ -11,7 +11,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.modern.McpException; import io.modelcontextprotocol.modern.McpSchema.AudioContent; import io.modelcontextprotocol.modern.McpSchema.BlobResourceContents; import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; @@ -255,6 +254,15 @@ private static Handler stream(StreamingTool to return (ctx, request) -> McpSyncResponse.streaming(notifier -> tool.run(ctx, request, notifier)); } + private static final ClientCapabilities.Elicitation FORM = new ClientCapabilities.Elicitation( + new ClientCapabilities.Elicitation.Form(), null); + + private static final ClientCapabilities ELICITATION = ClientCapabilities.builder().elicitation(FORM).build(); + + private static final ClientCapabilities SAMPLING = ClientCapabilities.builder().sampling().build(); + + private static final ClientCapabilities ROOTS = ClientCapabilities.builder().roots().build(); + private static final class ConformanceTools implements McpSyncToolRepository { private final Map tools = new LinkedHashMap<>(); @@ -316,12 +324,8 @@ private static final class ConformanceTools implements McpSyncToolRepository { .build(), respond((ctx, req) -> text("ok"))); // SEP-2575 diagnostic tools - add("test_missing_capability", "Test tool requiring sampling", (ctx, req) -> { - if (!ctx.clientCapabilities().supportsSampling()) { - throw McpException.missingClientCapability(ClientCapabilities.builder().sampling().build()); - } - return McpSyncResponse.result(text("Success")); - }); + add("test_missing_capability", "Test tool requiring sampling", SAMPLING, + respond((ctx, req) -> text("Success"))); add("test_streaming_elicitation", "Diagnostic tool validating response progress streams", stream((ctx, req, notifier) -> { notifier.progress(50, 100.0, null); @@ -342,7 +346,7 @@ private static final class ConformanceTools implements McpSyncToolRepository { // SEP-2322 MRTR tools add("test_input_required_result_elicitation", "MRTR: returns InputRequiredResult with elicitation request", - respond((ctx, req) -> { + ELICITATION, respond((ctx, req) -> { Map content = acceptedContent(req.inputResponses(), "user_name"); if (content == null || !(content.get("name") instanceof String name)) { return InputRequiredResult.builder() @@ -352,7 +356,7 @@ private static final class ConformanceTools implements McpSyncToolRepository { return text("Hello, " + name + "!"); })); add("test_input_required_result_sampling", "MRTR: returns InputRequiredResult with sampling request", - respond((ctx, req) -> { + SAMPLING, respond((ctx, req) -> { Map response = responseObject(req.inputResponses(), "capital_question"); if (response == null) { return InputRequiredResult.builder() @@ -364,7 +368,7 @@ private static final class ConformanceTools implements McpSyncToolRepository { return text("Sampling result: " + answer); })); add("test_input_required_result_list_roots", "MRTR: returns InputRequiredResult with roots/list request", - respond((ctx, req) -> { + ROOTS, respond((ctx, req) -> { Map response = responseObject(req.inputResponses(), "client_roots"); if (response == null || !(response.get("roots") instanceof List roots)) { return InputRequiredResult.builder().listRoots("client_roots").build(); @@ -372,7 +376,7 @@ private static final class ConformanceTools implements McpSyncToolRepository { return text("Found " + roots.size() + " root(s): " + roots); })); add("test_input_required_result_request_state", "MRTR: returns InputRequiredResult with requestState", - respond((ctx, req) -> { + ELICITATION, respond((ctx, req) -> { Map content = acceptedContent(req.inputResponses(), "confirm"); if ("request-state".equals(req.requestState()) && content != null && Boolean.TRUE.equals(content.get("ok"))) { @@ -384,7 +388,8 @@ private static final class ConformanceTools implements McpSyncToolRepository { .build(); })); add("test_input_required_result_multiple_inputs", - "MRTR: returns InputRequiredResult with multiple input requests", respond((ctx, req) -> { + "MRTR: returns InputRequiredResult with multiple input requests", + ClientCapabilities.builder().elicitation(FORM).sampling().roots().build(), respond((ctx, req) -> { Map user = acceptedContent(req.inputResponses(), "user_name"); Map greeting = responseObject(req.inputResponses(), "greeting"); Map roots = responseObject(req.inputResponses(), "client_roots"); @@ -400,7 +405,7 @@ private static final class ConformanceTools implements McpSyncToolRepository { .requestState("multiple-inputs") .build(); })); - add("test_input_required_result_multi_round", "MRTR: multi-round InputRequiredResult workflow", + add("test_input_required_result_multi_round", "MRTR: multi-round InputRequiredResult workflow", ELICITATION, respond((ctx, req) -> { String state = req.requestState(); if (state != null && state.startsWith("round-1")) { @@ -427,7 +432,7 @@ private static final class ConformanceTools implements McpSyncToolRepository { // Integrity protection comes from the server's RequestStateCodec: a tampered // requestState is rejected before this handler runs. add("test_input_required_result_tampered_state", "MRTR: HMAC-signed requestState integrity test", - respond((ctx, req) -> { + ELICITATION, respond((ctx, req) -> { if ("tamper-test".equals(req.requestState()) && responseObject(req.inputResponses(), "confirm") != null) { return text("integrity-ok: state verified"); @@ -459,11 +464,21 @@ && responseObject(req.inputResponses(), "confirm") != null) { } private void add(String name, String description, Handler handler) { - add(Tool.builder(name, EMPTY_JSON_SCHEMA).description(description).build(), handler); + add(name, description, ClientCapabilities.NONE, handler); + } + + private void add(String name, String description, ClientCapabilities requiredCapabilities, + Handler handler) { + add(Tool.builder(name, EMPTY_JSON_SCHEMA).description(description).build(), requiredCapabilities, handler); } private void add(Tool tool, Handler handler) { - this.tools.put(tool.name(), McpSyncTool.of(tool, handler::handle)); + add(tool, ClientCapabilities.NONE, handler); + } + + private void add(Tool tool, ClientCapabilities requiredCapabilities, + Handler handler) { + this.tools.put(tool.name(), McpSyncTool.of(tool, requiredCapabilities, handler::handle)); } @Override @@ -608,7 +623,7 @@ private static final class ConformancePrompts implements McpSyncPromptRepository .build()))); add(Prompt.builder("test_input_required_result_prompt") .description("MRTR: prompt that requires elicitation input") - .build(), respond((ctx, req) -> { + .build(), ELICITATION, respond((ctx, req) -> { Map content = acceptedContent(req.inputResponses(), "user_context"); if (content == null || !(content.get("context") instanceof String context)) { return InputRequiredResult.builder() @@ -622,7 +637,12 @@ private static final class ConformancePrompts implements McpSyncPromptRepository } private void add(Prompt prompt, Handler handler) { - this.prompts.put(prompt.name(), McpSyncPrompt.of(prompt, handler::handle)); + add(prompt, ClientCapabilities.NONE, handler); + } + + private void add(Prompt prompt, ClientCapabilities requiredCapabilities, + Handler handler) { + this.prompts.put(prompt.name(), McpSyncPrompt.of(prompt, requiredCapabilities, handler::handle)); } private static GetPromptResult messages(PromptMessage... messages) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java index 2ad233479..086c34cd8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpException.java @@ -42,6 +42,13 @@ public static McpException invalidParams(String message, Object data) { return new McpException(ErrorCodes.INVALID_PARAMS, message, data); } + /** + * The request cannot be served without {@code required}, which the client did not + * declare. Answered with {@code -32021}. Tools, prompts and resources declare such + * capabilities up front instead (see + * {@link io.modelcontextprotocol.modern.server.feature.McpAsyncTool}); this is for + * extension methods. + */ public static McpException missingClientCapability(ClientCapabilities required) { return new McpException(ErrorCodes.MISSING_REQUIRED_CLIENT_CAPABILITY, "Missing required client capability", new MissingRequiredClientCapabilityData(required)); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java index a413d1f50..e47edd32f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java @@ -192,6 +192,11 @@ public interface CacheableResult extends Result { * A result asking the client for more input before the request can complete. An * extension method supporting multi round-trip requests returns its own record * implementing this interface and the method's outcome type. + *

+ * Each input request needs a client capability: {@code elicitation} in the request's + * mode, {@code sampling} or {@code roots}. Only input whose capability the client + * declared for this request may be requested; a result asking for anything else is + * answered with an internal error. */ public interface InputRequired extends Result { @@ -1410,7 +1415,8 @@ public String mode() { /** * A result signalling that the server needs more input before it can complete the * original request. Only {@code tools/call}, {@code resources/read} and - * {@code prompts/get} may return it. + * {@code prompts/get} may return it, and only with the input requests + * {@link InputRequired} allows. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java index 9e83f9396..efb4558d1 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpFeature.java @@ -7,6 +7,7 @@ import java.util.Set; import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.InputRequired; import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.ServerCapabilities; import reactor.core.publisher.Mono; @@ -38,8 +39,8 @@ default void capabilities(ServerCapabilities.Builder builder) { /** * The methods that support multi round-trip requests: they may answer with an - * {@code InputRequired} result and accept its {@code requestState} on retry. Default: - * none. + * {@link InputRequired} result, within the input requests it allows, and accept its + * {@code requestState} on retry. Default: none. */ default Set inputRequiredMethods() { return Set.of(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestContext.java index 005e50240..361344927 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestContext.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpRequestContext.java @@ -9,6 +9,7 @@ import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.Implementation; +import io.modelcontextprotocol.modern.McpSchema.InputRequired; import io.modelcontextprotocol.util.Assert; /** @@ -76,7 +77,10 @@ public String protocolVersion() { return this.protocolVersion; } - /** The client capabilities declared in {@code _meta} for this request. */ + /** + * The client capabilities declared in {@code _meta} for this request. Input requests + * may only use these; see {@link InputRequired}. + */ public ClientCapabilities clientCapabilities() { return this.clientCapabilities; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java index de86c2321..45659bf3f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java @@ -27,6 +27,7 @@ import io.modelcontextprotocol.modern.McpSchema.CacheScope; import io.modelcontextprotocol.modern.McpSchema.CacheableResult; import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.McpSchema.ElicitUrlRequest; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.Implementation; import io.modelcontextprotocol.modern.McpSchema.InputRequired; @@ -238,6 +239,17 @@ private JSONRPCResponse resultResponse(McpRequestContext ctx, Result result) { return bug(ctx, "Method does not support input-required results"); } Map resultMap = new LinkedHashMap<>(this.jsonMapper.convertValue(result, MAP_TYPE_REF)); + if (result instanceof InputRequired) { + // Checked on the serialized form, so a request's elicitation mode is read the + // same way the client will read it. + String undeclared = undeclaredInputRequest(ctx.clientCapabilities(), resultMap.get("inputRequests")); + if (undeclared != null) { + return bug(ctx, + "Input request '" + undeclared + + "' needs a client capability the request did not declare; require the capability" + + " up front or only request input the client declared"); + } + } Map resultMeta = new LinkedHashMap<>(); if (resultMap.get("_meta") instanceof Map existingMeta) { existingMeta.forEach((k, v) -> resultMeta.put(String.valueOf(k), v)); @@ -267,6 +279,31 @@ private static JSONRPCResponse errorResponse(Object id, Throwable throwable) { return JSONRPCResponse.error(id, new JSONRPCError(ErrorCodes.INTERNAL_ERROR, "Internal error")); } + // The key of the first input request the client cannot answer, or null if it can + // answer all of them. Methods outside the spec's three are not checked. + private static String undeclaredInputRequest(ClientCapabilities declared, Object inputRequests) { + if (!(inputRequests instanceof Map requests)) { + return null; + } + for (Map.Entry entry : requests.entrySet()) { + if (!(entry.getValue() instanceof Map request)) { + continue; + } + boolean supported = switch (String.valueOf(request.get("method"))) { + case McpSchema.METHOD_ELICITATION_CREATE -> request.get("params") instanceof Map params + && ElicitUrlRequest.MODE.equals(params.get("mode")) ? declared.supportsElicitationUrl() + : declared.supportsElicitationForm(); + case McpSchema.METHOD_SAMPLING_CREATE_MESSAGE -> declared.supportsSampling(); + case McpSchema.METHOD_ROOTS_LIST -> declared.supportsRoots(); + default -> true; + }; + if (!supported) { + return String.valueOf(entry.getKey()); + } + } + return null; + } + private static JSONRPCResponse bug(McpRequestContext ctx, String problem) { logger.warn("Request {} ({}): {}", ctx.requestId(), ctx.method(), problem); return JSONRPCResponse.error(ctx.requestId(), new JSONRPCError(ErrorCodes.INTERNAL_ERROR, "Internal error")); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPrompt.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPrompt.java index d83815eb3..3b987c3de 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPrompt.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncPrompt.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.modern.server.feature; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; import io.modelcontextprotocol.modern.McpSchema.Prompt; @@ -14,6 +15,13 @@ /** * A prompt's definition together with the handler that renders it. + *

+ * Client capabilities the prompt cannot be rendered without belong in + * {@link #requiredCapabilities()}: a client that did not declare them gets {@code -32021} + * and the handler does not run. Input the prompt can do without, or can get another way, + * is requested only when {@link McpRequestContext#clientCapabilities()} declares its + * capability. Requesting input the client did not declare is answered with an internal + * error. * * @author Dariusz Jędrzejczyk */ @@ -21,23 +29,41 @@ public final class McpAsyncPrompt { private final Prompt definition; + private final ClientCapabilities requiredCapabilities; + private final Handler handler; - private McpAsyncPrompt(Prompt definition, Handler handler) { + private McpAsyncPrompt(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler) { Assert.notNull(definition, "definition must not be null"); + Assert.notNull(requiredCapabilities, "requiredCapabilities must not be null"); Assert.notNull(handler, "handler must not be null"); this.definition = definition; + this.requiredCapabilities = requiredCapabilities; this.handler = handler; } + /** A prompt that requires no client capabilities. */ public static McpAsyncPrompt of(Prompt definition, Handler handler) { - return new McpAsyncPrompt(definition, handler); + return new McpAsyncPrompt(definition, ClientCapabilities.NONE, handler); + } + + /** + * A prompt that only works with clients declaring {@code requiredCapabilities}. A + * request from any other client is rejected with {@code -32021} without running the + * handler. + */ + public static McpAsyncPrompt of(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler) { + return new McpAsyncPrompt(definition, requiredCapabilities, handler); } public Prompt definition() { return this.definition; } + public ClientCapabilities requiredCapabilities() { + return this.requiredCapabilities; + } + public Handler handler() { return this.handler; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResource.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResource.java index 4ca10f637..d9c33f200 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResource.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncResource.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.modern.server.feature; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.modern.server.McpAsyncResponse; @@ -13,20 +14,45 @@ /** * A readable resource: the handler that answers {@code resources/read} for it. + *

+ * Client capabilities the resource cannot be read without belong in + * {@link #requiredCapabilities()}: a client that did not declare them gets {@code -32021} + * and the handler does not run. Input the resource can do without, or can get another + * way, is requested only when {@link McpRequestContext#clientCapabilities()} declares its + * capability. Requesting input the client did not declare is answered with an internal + * error. * * @author Dariusz Jędrzejczyk */ public final class McpAsyncResource { + private final ClientCapabilities requiredCapabilities; + private final Handler handler; - private McpAsyncResource(Handler handler) { + private McpAsyncResource(ClientCapabilities requiredCapabilities, Handler handler) { + Assert.notNull(requiredCapabilities, "requiredCapabilities must not be null"); Assert.notNull(handler, "handler must not be null"); + this.requiredCapabilities = requiredCapabilities; this.handler = handler; } + /** A resource that requires no client capabilities. */ public static McpAsyncResource of(Handler handler) { - return new McpAsyncResource(handler); + return new McpAsyncResource(ClientCapabilities.NONE, handler); + } + + /** + * A resource that can only be read by clients declaring {@code requiredCapabilities}. + * A read from any other client is rejected with {@code -32021} without running the + * handler. + */ + public static McpAsyncResource of(ClientCapabilities requiredCapabilities, Handler handler) { + return new McpAsyncResource(requiredCapabilities, handler); + } + + public ClientCapabilities requiredCapabilities() { + return this.requiredCapabilities; } public Handler handler() { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncTool.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncTool.java index 76616da5f..c93d2e99f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncTool.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpAsyncTool.java @@ -6,6 +6,7 @@ import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.McpAsyncResponse; import io.modelcontextprotocol.modern.server.McpRequestContext; @@ -14,6 +15,13 @@ /** * A tool's definition together with the handler that answers its calls. + *

+ * Client capabilities the tool cannot work without belong in + * {@link #requiredCapabilities()}: a client that did not declare them gets {@code -32021} + * and the handler does not run. Input the tool can do without, or can get another way, is + * requested only when {@link McpRequestContext#clientCapabilities()} declares its + * capability. Requesting input the client did not declare is answered with an internal + * error. * * @author Dariusz Jędrzejczyk */ @@ -21,23 +29,40 @@ public final class McpAsyncTool { private final Tool definition; + private final ClientCapabilities requiredCapabilities; + private final Handler handler; - private McpAsyncTool(Tool definition, Handler handler) { + private McpAsyncTool(Tool definition, ClientCapabilities requiredCapabilities, Handler handler) { Assert.notNull(definition, "definition must not be null"); + Assert.notNull(requiredCapabilities, "requiredCapabilities must not be null"); Assert.notNull(handler, "handler must not be null"); this.definition = definition; + this.requiredCapabilities = requiredCapabilities; this.handler = handler; } + /** A tool that requires no client capabilities. */ public static McpAsyncTool of(Tool definition, Handler handler) { - return new McpAsyncTool(definition, handler); + return new McpAsyncTool(definition, ClientCapabilities.NONE, handler); + } + + /** + * A tool that only works with clients declaring {@code requiredCapabilities}. A call + * from any other client is rejected with {@code -32021} without running the handler. + */ + public static McpAsyncTool of(Tool definition, ClientCapabilities requiredCapabilities, Handler handler) { + return new McpAsyncTool(definition, requiredCapabilities, handler); } public Tool definition() { return this.definition; } + public ClientCapabilities requiredCapabilities() { + return this.requiredCapabilities; + } + public Handler handler() { return this.handler; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPrompt.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPrompt.java index 3f69f8797..90fb5d2e0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPrompt.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncPrompt.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.modern.server.feature; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome; import io.modelcontextprotocol.modern.McpSchema.GetPromptRequest; import io.modelcontextprotocol.modern.McpSchema.Prompt; @@ -20,23 +21,41 @@ public final class McpSyncPrompt { private final Prompt definition; + private final ClientCapabilities requiredCapabilities; + private final Handler handler; - private McpSyncPrompt(Prompt definition, Handler handler) { + private McpSyncPrompt(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler) { Assert.notNull(definition, "definition must not be null"); + Assert.notNull(requiredCapabilities, "requiredCapabilities must not be null"); Assert.notNull(handler, "handler must not be null"); this.definition = definition; + this.requiredCapabilities = requiredCapabilities; this.handler = handler; } + /** A prompt that requires no client capabilities. */ public static McpSyncPrompt of(Prompt definition, Handler handler) { - return new McpSyncPrompt(definition, handler); + return new McpSyncPrompt(definition, ClientCapabilities.NONE, handler); + } + + /** + * A prompt that only works with clients declaring {@code requiredCapabilities}. A + * request from any other client is rejected with {@code -32021} without running the + * handler. + */ + public static McpSyncPrompt of(Prompt definition, ClientCapabilities requiredCapabilities, Handler handler) { + return new McpSyncPrompt(definition, requiredCapabilities, handler); } public Prompt definition() { return this.definition; } + public ClientCapabilities requiredCapabilities() { + return this.requiredCapabilities; + } + public Handler handler() { return this.handler; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResource.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResource.java index 35de165e0..2664cb6b6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResource.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncResource.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.modern.server.feature; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; import io.modelcontextprotocol.modern.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.modern.server.McpRequestContext; @@ -17,15 +18,33 @@ */ public final class McpSyncResource { + private final ClientCapabilities requiredCapabilities; + private final Handler handler; - private McpSyncResource(Handler handler) { + private McpSyncResource(ClientCapabilities requiredCapabilities, Handler handler) { + Assert.notNull(requiredCapabilities, "requiredCapabilities must not be null"); Assert.notNull(handler, "handler must not be null"); + this.requiredCapabilities = requiredCapabilities; this.handler = handler; } + /** A resource that requires no client capabilities. */ public static McpSyncResource of(Handler handler) { - return new McpSyncResource(handler); + return new McpSyncResource(ClientCapabilities.NONE, handler); + } + + /** + * A resource that can only be read by clients declaring {@code requiredCapabilities}. + * A read from any other client is rejected with {@code -32021} without running the + * handler. + */ + public static McpSyncResource of(ClientCapabilities requiredCapabilities, Handler handler) { + return new McpSyncResource(requiredCapabilities, handler); + } + + public ClientCapabilities requiredCapabilities() { + return this.requiredCapabilities; } public Handler handler() { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncTool.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncTool.java index 90016dcdc..0afec8f22 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncTool.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpSyncTool.java @@ -6,6 +6,7 @@ import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpSyncResponse; @@ -20,23 +21,40 @@ public final class McpSyncTool { private final Tool definition; + private final ClientCapabilities requiredCapabilities; + private final Handler handler; - private McpSyncTool(Tool definition, Handler handler) { + private McpSyncTool(Tool definition, ClientCapabilities requiredCapabilities, Handler handler) { Assert.notNull(definition, "definition must not be null"); + Assert.notNull(requiredCapabilities, "requiredCapabilities must not be null"); Assert.notNull(handler, "handler must not be null"); this.definition = definition; + this.requiredCapabilities = requiredCapabilities; this.handler = handler; } + /** A tool that requires no client capabilities. */ public static McpSyncTool of(Tool definition, Handler handler) { - return new McpSyncTool(definition, handler); + return new McpSyncTool(definition, ClientCapabilities.NONE, handler); + } + + /** + * A tool that only works with clients declaring {@code requiredCapabilities}. A call + * from any other client is rejected with {@code -32021} without running the handler. + */ + public static McpSyncTool of(Tool definition, ClientCapabilities requiredCapabilities, Handler handler) { + return new McpSyncTool(definition, requiredCapabilities, handler); } public Tool definition() { return this.definition; } + public ClientCapabilities requiredCapabilities() { + return this.requiredCapabilities; + } + public Handler handler() { return this.handler; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java index 557defa11..3315619b7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/PromptsFeature.java @@ -93,6 +93,10 @@ public Mono> handle(McpRequestConte private Mono> get(McpRequestContext ctx, McpAsyncPrompt prompt, GetPromptRequest request) { + McpException missing = RequiredCapabilities.check(prompt.requiredCapabilities(), ctx); + if (missing != null) { + return Mono.error(missing); + } List declared = prompt.definition().arguments(); if (declared != null) { Map arguments = request.arguments() != null ? request.arguments() : Map.of(); @@ -138,7 +142,7 @@ public Mono find(McpRequestContext ctx, String name) { } private static McpAsyncPrompt adapt(McpSyncPrompt prompt) { - return McpAsyncPrompt.of(prompt.definition(), + return McpAsyncPrompt.of(prompt.definition(), prompt.requiredCapabilities(), (ctx, request) -> SyncAdapters.respond(ctx, () -> prompt.handler().get(ctx, request))); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/RequiredCapabilities.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/RequiredCapabilities.java new file mode 100644 index 000000000..07c7e0f83 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/RequiredCapabilities.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server.feature; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.modern.server.McpRequestContext; + +final class RequiredCapabilities { + + private RequiredCapabilities() { + } + + // A -32021 listing the required capabilities the client did not declare, or null if + // it declared all of them. + static McpException check(ClientCapabilities required, McpRequestContext ctx) { + ClientCapabilities missing = missing(required, ctx.clientCapabilities()); + return missing != null ? McpException.missingClientCapability(missing) : null; + } + + private static ClientCapabilities missing(ClientCapabilities required, ClientCapabilities declared) { + boolean roots = required.supportsRoots() && !declared.supportsRoots(); + boolean sampling = required.supportsSampling() && !declared.supportsSampling(); + boolean form = required.supportsElicitationForm() && !declared.supportsElicitationForm(); + boolean url = required.supportsElicitationUrl() && !declared.supportsElicitationUrl(); + Map experimental = missingKeys(required.experimental(), declared.experimental()); + Map> extensions = missingKeys(required.extensions(), declared.extensions()); + if (!roots && !sampling && !form && !url && experimental == null && extensions == null) { + return null; + } + ClientCapabilities.Builder builder = ClientCapabilities.builder() + .experimental(experimental) + .extensions(extensions); + if (roots) { + builder.roots(); + } + if (sampling) { + builder.sampling(); + } + if (form || url) { + builder + .elicitation(new ClientCapabilities.Elicitation(form ? new ClientCapabilities.Elicitation.Form() : null, + url ? new ClientCapabilities.Elicitation.Url() : null)); + } + return builder.build(); + } + + private static Map missingKeys(Map required, Map declared) { + if (required == null) { + return null; + } + Map missing = new LinkedHashMap<>(required); + if (declared != null) { + missing.keySet().removeAll(declared.keySet()); + } + return missing.isEmpty() ? null : missing; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java index 99c8b7819..9b03d28be 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeature.java @@ -86,7 +86,7 @@ public Mono> handle(McpRequestConte .flatMap(request -> this.repository.find(ctx, request.uri()) .switchIfEmpty(Mono.error(() -> McpException.invalidParams("Unknown resource: " + request.uri(), Map.of("uri", request.uri())))) - .flatMap(resource -> resource.handler().read(ctx, request))); + .flatMap(resource -> read(ctx, resource, request))); } Mono request = Params.decode(this.jsonMapper, params, PaginatedRequest.class); if (McpSchema.METHOD_RESOURCES_LIST.equals(ctx.method())) { @@ -97,6 +97,15 @@ public Mono> handle(McpRequestConte .map(page -> McpAsyncResponse.result(toListTemplatesResult(page))); } + private Mono> read(McpRequestContext ctx, McpAsyncResource resource, + ReadResourceRequest request) { + McpException missing = RequiredCapabilities.check(resource.requiredCapabilities(), ctx); + if (missing != null) { + return Mono.error(missing); + } + return resource.handler().read(ctx, request); + } + @Override public void capabilities(ServerCapabilities.Builder builder) { builder.resources(this.repository.supportsSubscribe(), false); @@ -148,8 +157,8 @@ public boolean supportsSubscribe() { } private static McpAsyncResource adapt(McpSyncResource resource) { - return McpAsyncResource - .of((ctx, request) -> SyncAdapters.respond(ctx, () -> resource.handler().read(ctx, request))); + return McpAsyncResource.of(resource.requiredCapabilities(), + (ctx, request) -> SyncAdapters.respond(ctx, () -> resource.handler().read(ctx, request))); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java index ea71eb5bf..55b8ea8a3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/ToolsFeature.java @@ -105,6 +105,10 @@ public Mono> handle(McpRequestConte private Mono> call(McpRequestContext ctx, McpAsyncTool tool, CallToolRequest request) { + McpException missing = RequiredCapabilities.check(tool.requiredCapabilities(), ctx); + if (missing != null) { + return Mono.error(missing); + } Map arguments = request.arguments() != null ? request.arguments() : Map.of(); ValidationResponse input = this.jsonSchemaValidator.validate(tool.definition().inputSchema(), arguments); if (!input.valid()) { @@ -190,7 +194,7 @@ public Mono find(McpRequestContext ctx, String name) { } private static McpAsyncTool adapt(McpSyncTool tool) { - return McpAsyncTool.of(tool.definition(), + return McpAsyncTool.of(tool.definition(), tool.requiredCapabilities(), (ctx, request) -> SyncAdapters.respond(ctx, () -> tool.handler().call(ctx, request))); } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java index 2c5379fdb..105f92bd0 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerMrtrTests.java @@ -13,9 +13,9 @@ import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; -import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.CacheScope; import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.ElicitFormRequest; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.InputRequest; @@ -26,6 +26,7 @@ import io.modelcontextprotocol.modern.McpSchema.Result; import io.modelcontextprotocol.modern.McpSchema.TextContent; import io.modelcontextprotocol.modern.McpSchema.TextResourceContents; +import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.server.feature.McpAsyncResource; import io.modelcontextprotocol.modern.server.feature.McpAsyncResourceRepository; import io.modelcontextprotocol.modern.server.feature.ResourcesFeature; @@ -51,6 +52,10 @@ private static McpServer.Builder baseBuilder() { return McpServer.builder().serverInfo(SERVER_INFO); } + private static final ClientCapabilities FORM_ELICITATION = ClientCapabilities.builder() + .elicitation(new ClientCapabilities.Elicitation(new ClientCapabilities.Elicitation.Form(), null)) + .build(); + private static Map metaWithElicitation() { Map meta = meta(); meta.put(MetaKeys.CLIENT_CAPABILITIES, Map.of("elicitation", Map.of("form", Map.of()))); @@ -68,7 +73,7 @@ private static JSONRPCRequest toolCall(Object id, Map meta, Obje } private static McpServer serverRecordingState(AtomicReference seenRequestState) { - return baseBuilder().feature(ToolsFeature.ofAsync(tools((ctx, req) -> { + return baseBuilder().feature(ToolsFeature.ofAsync(tools(FORM_ELICITATION, (ctx, req) -> { if (req.requestState() != null) { seenRequestState.set(req.requestState()); return Mono.just(McpAsyncResponse.result(CallToolResult.builder().build())); @@ -137,7 +142,7 @@ void malformedInputResponsesAreRejectedBeforeTheHandler() { @Test @SuppressWarnings("unchecked") void unparseableInputResponseIsInvalidParams() { - McpServer server = baseBuilder().feature(ToolsFeature.ofAsync(tools((ctx, req) -> { + McpServer server = baseBuilder().feature(ToolsFeature.ofAsync(tools(FORM_ELICITATION, (ctx, req) -> { InputResponses.get(req.inputResponses(), "q1", ElicitAnswer.class, McpJsonDefaults.getMapper()); return Mono.just(McpAsyncResponse.result(CallToolResult.builder().build())); }), McpJsonDefaults.getMapper(), PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)).build(); @@ -154,21 +159,88 @@ void unparseableInputResponseIsInvalidParams() { record ElicitAnswer(String action, Map content) { } + private static McpServer serverRequesting(InputRequiredResult result) { + return baseBuilder() + .feature(ToolsFeature.ofAsync(tools((ctx, req) -> Mono.just(McpAsyncResponse.result(result))), + McpJsonDefaults.getMapper(), PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)) + .build(); + } + + private static Map metaDeclaring(Map clientCapabilities) { + Map meta = meta(); + meta.put(MetaKeys.CLIENT_CAPABILITIES, clientCapabilities); + return meta; + } + + @Test + void inputRequestForAnUndeclaredCapabilityIsAnInternalError() { + McpServer server = serverRequesting(InputRequiredResult.builder() + .elicit("q1", CONFIRM) + .createMessage("q2", McpSchema.CreateMessageRequest.builder(List.of(), 10).build()) + .build()); + + JSONRPCResponse response = respond(server, toolCall(1, metaDeclaring(Map.of("elicitation", Map.of())), null)) + .block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR); + } + + @Test + void urlElicitationNeedsTheUrlMode() { + McpServer server = serverRequesting( + InputRequiredResult.builder().elicitUrl("q1", "Sign in", "https://example.com/login").build()); + + JSONRPCResponse formOnly = respond(server, toolCall(1, metaWithElicitation(), null)).block(); + JSONRPCResponse withUrl = respond(server, + toolCall(2, metaDeclaring(Map.of("elicitation", Map.of("url", Map.of()))), null)) + .block(); + + assertThat(formOnly.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR); + assertThat(withUrl.error()).isNull(); + } + @Test - void inputRequestsAreNotCheckedAgainstClientCapabilities() { - // Checking capabilities is the handler's job; the server sends what it is given. + void inputRequestsForDeclaredCapabilitiesAreSent() { + McpServer server = serverRequesting(InputRequiredResult.builder() + .elicit("q1", CONFIRM) + .createMessage("q2", McpSchema.CreateMessageRequest.builder(List.of(), 10).build()) + .listRoots("q3") + .build()); + + JSONRPCResponse response = respond(server, toolCall(1, + metaDeclaring(Map.of("elicitation", Map.of(), "sampling", Map.of(), "roots", Map.of())), null)) + .block(); + + assertThat(response.error()).isNull(); + } + + @Test + void resourceInputRequestsAreCheckedToo() { + McpAsyncResourceRepository repo = new McpAsyncResourceRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ResourcesPage.of(List.of())); + } + + @Override + public Mono find(McpRequestContext ctx, String uri) { + return Mono.just(McpAsyncResource.of(this::read)); + } + + private Mono> read(McpRequestContext ctx, + McpSchema.ReadResourceRequest request) { + return Mono.just(McpAsyncResponse.result(InputRequiredResult.builder().listRoots("q1").build())); + } + }; McpServer server = baseBuilder() - .feature( - ToolsFeature.ofAsync( - tools((ctx, - req) -> Mono.just(McpAsyncResponse - .result(InputRequiredResult.builder().elicit("q1", CONFIRM).build()))), - McpJsonDefaults.getMapper(), PERMISSIVE_VALIDATOR, 0L, CacheScope.PRIVATE)) + .feature(ResourcesFeature.ofAsync(repo, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) .build(); - JSONRPCResponse response = respond(server, toolCall(1, meta(), null)).block(); + JSONRPCResponse response = respond(server, + new JSONRPCRequest("resources/read", 1, Map.of("_meta", meta(), "uri", "file:///a.txt"))) + .block(); - assertThat(response.error()).isNull(); + assertThat(response.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR); } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java index 7b15a2ccf..f163e5cae 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/ModernTestFixtures.java @@ -17,6 +17,7 @@ import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; import io.modelcontextprotocol.modern.McpSchema.CallToolRequest; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.Implementation; import io.modelcontextprotocol.modern.McpSchema.MetaKeys; import io.modelcontextprotocol.modern.McpSchema.Tool; @@ -75,6 +76,15 @@ public static Mono respond(McpRequestManager manager, JSONRPCRe */ public static McpAsyncToolRepository tools( BiFunction>> call) { + return tools(ClientCapabilities.NONE, call); + } + + /** + * Like {@link #tools(BiFunction)}, with every tool requiring + * {@code requiredCapabilities}. + */ + public static McpAsyncToolRepository tools(ClientCapabilities requiredCapabilities, + BiFunction>> call) { return new McpAsyncToolRepository() { @Override public Mono list(McpRequestContext ctx, String cursor) { @@ -83,8 +93,8 @@ public Mono list(McpRequestContext ctx, String cursor) { @Override public Mono find(McpRequestContext ctx, String name) { - return Mono - .just(McpAsyncTool.of(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build(), call::apply)); + return Mono.just(McpAsyncTool.of(Tool.builder(name, ToolsUtils.EMPTY_JSON_SCHEMA).build(), + requiredCapabilities, call::apply)); } }; } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java index e8ed91844..9530ae017 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/PromptsFeatureTests.java @@ -6,11 +6,13 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; import io.modelcontextprotocol.modern.McpSchema.CacheScope; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.GetPromptResult; import io.modelcontextprotocol.modern.McpSchema.Prompt; @@ -73,6 +75,36 @@ void missingRequiredArgumentIsInvalidParams() { assertThat(response.error().message()).isEqualTo("Missing required argument: name"); } + @Test + void missingRequiredCapabilityIsRejectedWithoutRendering() { + AtomicBoolean called = new AtomicBoolean(); + Prompt sampled = Prompt.builder("sampled").build(); + McpServer server = McpServer.builder() + .serverInfo(SERVER_INFO) + .feature(PromptsFeature.ofAsync(new McpAsyncPromptRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(PromptsPage.of(List.of(sampled))); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.just(McpAsyncPrompt.of(sampled, ClientCapabilities.builder().sampling().build(), + (c, request) -> { + called.set(true); + return Mono.just(McpAsyncResponse.result(GetPromptResult.builder(List.of()).build())); + })); + } + }, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)) + .build(); + JSONRPCRequest request = new JSONRPCRequest("prompts/get", 1, Map.of("_meta", meta(), "name", "sampled")); + + JSONRPCResponse response = respond(server, request).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.MISSING_REQUIRED_CLIENT_CAPABILITY); + assertThat(called).isFalse(); + } + @Test void optionalArgumentMayBeOmitted() { JSONRPCResponse response = get("greeting", Map.of("name", "alice")); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeatureTests.java index a9a00b2ef..9be584d38 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ResourcesFeatureTests.java @@ -6,13 +6,16 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCRequest; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome; +import io.modelcontextprotocol.modern.McpSchema.ReadResourceResult; import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.server.McpAsyncResponse; import io.modelcontextprotocol.modern.server.McpRequestContext; @@ -67,6 +70,23 @@ void unknownResourceIsInvalidParamsCarryingItsUri() { assertThat(response.error().data()).isEqualTo(Map.of("uri", "test://missing")); } + @Test + void missingRequiredCapabilityIsRejectedWithoutReading() { + AtomicBoolean called = new AtomicBoolean(); + McpServer server = server( + finding(Mono.just(McpAsyncResource.of(ClientCapabilities.builder().roots().build(), (ctx, request) -> { + called.set(true); + return Mono.just(McpAsyncResponse.result(ReadResourceResult.builder(List.of()).build())); + })))); + JSONRPCRequest request = new JSONRPCRequest(McpSchema.METHOD_RESOURCES_READ, 1, + Map.of("_meta", meta(), "uri", "test://roots")); + + JSONRPCResponse response = respond(server, request).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.MISSING_REQUIRED_CLIENT_CAPABILITY); + assertThat(called).isFalse(); + } + @Test void readFailureIsAnsweredWithItsError() { McpServer server = server( diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java index cba1299e9..126d53ed0 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/feature/ToolsFeatureTests.java @@ -24,6 +24,7 @@ import io.modelcontextprotocol.modern.McpSchema.CacheScope; import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome; import io.modelcontextprotocol.modern.McpSchema.CallToolResult; +import io.modelcontextprotocol.modern.McpSchema.ClientCapabilities; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.MetaKeys; import io.modelcontextprotocol.modern.McpSchema.TextContent; @@ -185,6 +186,107 @@ void structuredContentGetsATextFallback() { assertThat(greetingText(response)).isEqualTo("{\"n\":1}"); } + private static final ClientCapabilities FORM_ELICITATION = ClientCapabilities.builder() + .elicitation(new ClientCapabilities.Elicitation(null, null)) + .build(); + + private static McpAsyncToolRepository requiring(ClientCapabilities required, AtomicBoolean called) { + return new McpAsyncToolRepository() { + @Override + public Mono list(McpRequestContext ctx, String cursor) { + return Mono.just(ToolsPage.of(List.of(ECHO_TOOL))); + } + + @Override + public Mono find(McpRequestContext ctx, String name) { + return Mono.just(McpAsyncTool.of(ECHO_TOOL, required, (c, request) -> { + called.set(true); + return Mono.just(McpAsyncResponse.result(greeting("client"))); + })); + } + }; + } + + private static JSONRPCRequest callEchoDeclaring(Map clientCapabilities) { + return new JSONRPCRequest("tools/call", 1, + Map.of("_meta", meta(MetaKeys.CLIENT_CAPABILITIES, clientCapabilities), "name", "echo")); + } + + @SuppressWarnings("unchecked") + private static Map requiredCapabilities(JSONRPCResponse response) { + Map data = McpJsonDefaults.getMapper().convertValue(response.error().data(), Map.class); + return (Map) data.get("requiredCapabilities"); + } + + @Test + void missingRequiredCapabilityIsRejectedWithoutCallingTheTool() { + AtomicBoolean called = new AtomicBoolean(); + McpServer server = baseBuilder().feature(tools(requiring(FORM_ELICITATION, called))).build(); + + JSONRPCResponse response = respond(server, callEchoDeclaring(Map.of())).block(); + + assertThat(response.error().code()).isEqualTo(ErrorCodes.MISSING_REQUIRED_CLIENT_CAPABILITY); + assertThat(requiredCapabilities(response)).isEqualTo(Map.of("elicitation", Map.of("form", Map.of()))); + assertThat(called).isFalse(); + } + + @Test + void declaredRequiredCapabilityLetsTheCallThrough() { + AtomicBoolean called = new AtomicBoolean(); + McpServer server = baseBuilder().feature(tools(requiring(FORM_ELICITATION, called))).build(); + + JSONRPCResponse response = respond(server, callEchoDeclaring(Map.of("elicitation", Map.of()))).block(); + + assertThat(greetingText(response)).isEqualTo("hello client"); + assertThat(called).isTrue(); + } + + @Test + void onlyTheMissingCapabilitiesAreReported() { + ClientCapabilities required = ClientCapabilities.builder() + .sampling() + .elicitation(new ClientCapabilities.Elicitation(new ClientCapabilities.Elicitation.Form(), + new ClientCapabilities.Elicitation.Url())) + .extensions(Map.of("io.example/a", Map.of(), "io.example/b", Map.of())) + .build(); + McpServer server = baseBuilder().feature(tools(requiring(required, new AtomicBoolean()))).build(); + + JSONRPCResponse response = respond(server, callEchoDeclaring( + Map.of("sampling", Map.of(), "elicitation", Map.of(), "extensions", Map.of("io.example/a", Map.of())))) + .block(); + + assertThat(requiredCapabilities(response)) + .isEqualTo(Map.of("elicitation", Map.of("url", Map.of()), "extensions", Map.of("io.example/b", Map.of()))); + } + + @Test + void syncToolRequirementIsCheckedBeforeTheStreamStarts() { + AtomicBoolean called = new AtomicBoolean(); + McpSyncToolRepository repo = new McpSyncToolRepository() { + @Override + public ToolsPage list(McpRequestContext ctx, String cursor) { + return ToolsPage.of(List.of(ECHO_TOOL)); + } + + @Override + public McpSyncTool find(McpRequestContext ctx, String name) { + return McpSyncTool.of(ECHO_TOOL, ClientCapabilities.builder().sampling().build(), + (c, request) -> McpSyncResponse.streaming(notifier -> { + called.set(true); + return greeting("client"); + })); + } + }; + McpServer server = baseBuilder().feature(tools(repo)).build(); + + McpTransportResponse response = server.handle(McpTransportContext.EMPTY, callEchoDeclaring(Map.of())).block(); + + assertThat(response).isInstanceOf(McpTransportResponse.Error.class); + assertThat(((McpTransportResponse.Error) response).response().error().code()) + .isEqualTo(ErrorCodes.MISSING_REQUIRED_CLIENT_CAPABILITY); + assertThat(called).isFalse(); + } + @Test void negativeTtlIsRejected() { assertThatIllegalArgumentException().isThrownBy(() -> ToolsFeature.ofAsync(emptyTools(),