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(),