From 8751ad453360d330bdaa1809a0c72a682c69d432 Mon Sep 17 00:00:00 2001 From: Param Parikh Date: Thu, 27 Aug 2026 23:06:03 +0000 Subject: [PATCH 1/3] fix(mcp-server): Return -32601 for unknown JSON-RPC methods Per JSON-RPC 2.0, a request carrying an id must receive a response; the server previously returned null for unknown methods, which surfaced as an HTTP 200 with an empty body through HTTP transports. Notifications (requests without an id) are still silently dropped. This also makes MCP 2026-07-28 clients' auto mode fall back to the legacy handshake via a clean, deterministic -32601 on the server/discover probe instead of relying on a synthesized parse error from the empty body. https://issues.amazon.com/issues/APPDEV-2256 --- .../smithy/java/mcp/server/McpService.java | 28 ++++++-- .../smithy/java/mcp/server/McpServerTest.java | 71 +++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java index d22343b2d7..04e15fb914 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java @@ -77,6 +77,7 @@ public final class McpService { private static final InternalLogger LOG = InternalLogger.getLogger(McpService.class); private static final Context.Key ASYNC_DISPATCH = Context.key("mcp.asyncDispatch"); + private static final int METHOD_NOT_FOUND_ERROR_CODE = -32601; private static final JsonCodec CODEC = JsonCodec.builder() .settings(JsonSettings.builder() @@ -131,8 +132,8 @@ public final class McpService { *
  • Synchronous (return value): For most requests, the response is returned directly.
  • *
  • Asynchronous (callback): For proxy tool calls, returns {@code null} and the callback * is invoked when the proxy responds.
  • - *
  • Neither: For notifications and unknown methods, returns {@code null} and the callback - * is never invoked.
  • + *
  • Neither: For notifications, returns {@code null} and the callback is never + * invoked. Requests with unknown methods receive a -32601 (Method not found) error.
  • * * * @param req The JSON-RPC request to handle @@ -172,7 +173,7 @@ yield switch (method) { case "tools/list" -> handleToolsList(currentReq, protocolVersion); case "tools/call" -> handleToolsCall(currentReq, asyncResponseCallback, protocolVersion, hook); - default -> null; + default -> methodNotFound(currentReq); }; } }; @@ -211,7 +212,7 @@ yield switch (method) { case "tools/list" -> handleToolsList(req, protocolVersion); case "tools/call" -> handleToolsCallDirect(req, asyncResponseCallback, protocolVersion); - default -> null; // Notifications or unknown methods + default -> methodNotFound(req); }; } }; @@ -798,6 +799,25 @@ private JsonRpcResponse createErrorResponse(JsonRpcRequest req, String s) { .build(); } + /** + * Per JSON-RPC 2.0, a request with an unknown method must receive a -32601 (Method not found) + * error, while notifications (requests without an id) must never receive a response. + */ + private static JsonRpcResponse methodNotFound(JsonRpcRequest req) { + if (req.getId() == null) { + return null; + } + var error = JsonRpcErrorResponse.builder() + .code(METHOD_NOT_FOUND_ERROR_CODE) + .message("Method not found: " + req.getMethod()) + .build(); + return JsonRpcResponse.builder() + .id(req.getId()) + .error(error) + .jsonrpc("2.0") + .build(); + } + private Map createTools(Map services) { var tools = new ConcurrentHashMap(); for (var entry : services.entrySet()) { diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java index 326fef7d86..88c7abfe6f 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java @@ -754,6 +754,77 @@ void testNotificationsDoNotRequireRequestId() { assertNotNull(response.getResult()); } + @Test + void testUnknownMethodReturnsMethodNotFound() { + server = McpServer.builder() + .name("smithy-mcp-server") + .input(input) + .output(output) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + write("nonexistent/method", Document.of(Map.of()), Document.of(42)); + var response = read(); + assertEquals("2.0", response.getJsonrpc()); + assertEquals(42, response.getId().asNumber().intValue()); + assertNull(response.getResult()); + assertNotNull(response.getError()); + assertEquals(-32601, response.getError().getCode()); + assertTrue(response.getError().getMessage().contains("nonexistent/method")); + + // String ids must be echoed back with their original type + write("server/discover", Document.of(Map.of()), Document.of("discover-1")); + response = read(); + assertEquals("discover-1", response.getId().asString()); + assertNull(response.getResult()); + assertEquals(-32601, response.getError().getCode()); + + // Known methods are unaffected + write("ping", Document.of(Map.of()), Document.of(43)); + response = read(); + assertEquals(43, response.getId().asNumber().intValue()); + assertNull(response.getError()); + assertNotNull(response.getResult()); + } + + @Test + void testUnknownNotificationIsSilentlyDropped() { + server = McpServer.builder() + .name("smithy-mcp-server") + .input(input) + .output(output) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + // Unknown notifications (no id) must not receive a Method not found error + writeNotification("notifications/does-not-exist", Document.of(Map.of())); + output.assertNoOutput(); + + // Known notifications remain silently dropped + writeNotification("notifications/initialized", Document.of(Map.of())); + output.assertNoOutput(); + + // The next response on the wire belongs to the follow-up request, not a late error + write("tools/list", Document.of(Map.of()), Document.of(7)); + var response = read(); + assertEquals(7, response.getId().asNumber().intValue()); + assertNotNull(response.getResult()); + } + @Test void testPromptsList() { server = McpServer.builder() From af73ee0fbca876085a393dccee4a724553e86f82 Mon Sep 17 00:00:00 2001 From: Param Parikh Date: Fri, 28 Aug 2026 00:06:22 +0000 Subject: [PATCH 2/3] fix(mcp-server): Answer latest supported version for unknown protocol versions Per the MCP spec, a server that does not support the requested protocol version must respond with the latest version it supports. Previously handleInitialize left protocolVersion unset for unknown versions, so the Smithy model default (2024-11-05, the oldest version) leaked into InitializeResult. Add ProtocolVersion.latestVersion(), derived from the supported-version registry so it cannot drift as versions are added. The registry moves into an initialization-on-demand holder: computing the latest during class init of the sealed base class NPEs when a subclass INSTANCE is the first member of the hierarchy touched (base clinit reads a still-null INSTANCE; caught by McpServerIntegrationTest). Known versions are still echoed as-is; a missing protocolVersion param keeps its existing behavior. https://issues.amazon.com/issues/APPDEV-2257 --- .../smithy/java/mcp/server/McpService.java | 6 ++- .../java/mcp/server/ProtocolVersion.java | 41 +++++++++++++++---- .../smithy/java/mcp/server/McpServerTest.java | 23 +++++++++++ .../java/mcp/server/ProtocolVersionTest.java | 6 +++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java index 04e15fb914..6d7fd679f7 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java @@ -296,7 +296,11 @@ private JsonRpcResponse handleInitialize(JsonRpcRequest req) { String pv = null; if (maybeVersion != null) { var protocolVersion = ProtocolVersion.version(maybeVersion.asString()); - if (!(protocolVersion instanceof ProtocolVersion.UnknownVersion)) { + if (protocolVersion instanceof ProtocolVersion.UnknownVersion) { + // Per the MCP spec, a server that does not support the requested protocol + // version must respond with the latest version it supports. + pv = ProtocolVersion.latestVersion().identifier(); + } else { pv = protocolVersion.identifier(); } } diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/ProtocolVersion.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/ProtocolVersion.java index 555559a3b8..f5b8a62543 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/ProtocolVersion.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/ProtocolVersion.java @@ -5,6 +5,8 @@ package software.amazon.smithy.java.mcp.server; +import java.util.Collections; +import java.util.List; import software.amazon.smithy.utils.SmithyUnstableApi; @SmithyUnstableApi @@ -49,6 +51,20 @@ private UnknownVersion(String identifier) { } } + /** + * Holder defers initialization until first use so the version subclasses are fully loaded + * first — a plain static field on this class would read a still-null INSTANCE whenever a + * subclass is the first member of the hierarchy to be initialized. + */ + private static final class SupportedVersions { + private static final List ALL = List.of( + v2024_11_05.INSTANCE, + v2025_03_26.INSTANCE, + v2025_06_18.INSTANCE, + v2025_11_25.INSTANCE); + private static final ProtocolVersion LATEST = Collections.max(ALL); + } + private final String identifier; private ProtocolVersion(String identifier) { @@ -72,17 +88,26 @@ public final int compareTo(ProtocolVersion o) { } public static ProtocolVersion version(String identifier) { - return switch (identifier) { - case null -> v2025_03_26.INSTANCE; - case "2024-11-05" -> v2024_11_05.INSTANCE; - case "2025-03-26" -> v2025_03_26.INSTANCE; - case "2025-06-18" -> v2025_06_18.INSTANCE; - case "2025-11-25" -> v2025_11_25.INSTANCE; - default -> new UnknownVersion(identifier); - }; + if (identifier == null) { + return defaultVersion(); + } + for (var version : SupportedVersions.ALL) { + if (version.identifier.equals(identifier)) { + return version; + } + } + return new UnknownVersion(identifier); } public static ProtocolVersion defaultVersion() { return v2025_03_26.INSTANCE; } + + /** + * The most recent protocol version this server supports, derived from the supported-version + * registry. + */ + public static ProtocolVersion latestVersion() { + return SupportedVersions.LATEST; + } } diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java index 88c7abfe6f..37ee6a2f81 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java @@ -154,6 +154,29 @@ public void initializeWithV2025_11_25ProtocolVersion() { assertTrue(outputSchema.get("properties").asStringMap().containsKey("outputStr")); } + @Test + public void initializeWithUnknownProtocolVersionAnswersLatestSupported() { + server = McpServer.builder() + .name("smithy-mcp-server") + .input(input) + .output(output) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + // Per the MCP spec, a server answers a request for a version it does not support with + // the latest version it supports — not the oldest. + write("initialize", Document.of(Map.of("protocolVersion", Document.of("2026-07-28")))); + var pv = read().getResult().getMember("protocolVersion").asString(); + assertEquals("2025-11-25", pv); + } + @Test public void noOutputSchemaWithUnsupportedProtocolVersion() { server = McpServer.builder() diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/ProtocolVersionTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/ProtocolVersionTest.java index d95017e472..5d08c0596d 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/ProtocolVersionTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/ProtocolVersionTest.java @@ -39,6 +39,12 @@ void defaultVersionIs2025_03_26() { assertEquals("2025-03-26", ProtocolVersion.defaultVersion().identifier()); } + @Test + void latestVersionIs2025_11_25() { + assertInstanceOf(ProtocolVersion.v2025_11_25.class, ProtocolVersion.latestVersion()); + assertEquals("2025-11-25", ProtocolVersion.latestVersion().identifier()); + } + @Test void compareToOrdersChronologically() { assertTrue(ProtocolVersion.v2024_11_05.INSTANCE.compareTo(ProtocolVersion.v2025_03_26.INSTANCE) < 0); From 6dd462403b1afb3b2b30ef5202ad9c33d7e6ffe0 Mon Sep 17 00:00:00 2001 From: Param Parikh Date: Fri, 28 Aug 2026 04:38:09 +0000 Subject: [PATCH 3/3] feat(mcp-server): Implement server/discover and UnsupportedProtocolVersionError MCP 2026-07-28 requires servers to implement server/discover, which advertises supported protocol versions, capabilities, and identity, and to reject requests naming an unsupported protocol version (via the io.modelcontextprotocol/protocolVersion key of params._meta) with UnsupportedProtocolVersionError (-32022) listing the versions the server does support. server/discover answers a wire-complete DiscoverResult advertising the handshake-era versions from the ProtocolVersion registry, newest first. Advertising no modern version is an explicit legacy advertisement that dual-era clients (Python/TypeScript/Go SDKs) answer by falling back to the initialize handshake deterministically, instead of inferring legacy-ness from an error. As the negotiation bootstrap, discover is answered regardless of the protocol version the request carries; initialize likewise stays exempt from the version guard. Requests without a _meta protocol version are served unchanged, so existing handshake-era traffic is unaffected. New Smithy shapes DiscoverResult and UnsupportedProtocolVersionErrorData model the 2026-07-28 wire contract; supported version identifiers come from a single registry-derived list so future versions update discover, the error data, and version lookup together. https://issues.amazon.com/issues/APPDEV-2258 --- mcp/mcp-schemas/model/main.smithy | 34 ++++ .../smithy/java/mcp/server/McpService.java | 135 +++++++++++++--- .../java/mcp/server/ProtocolVersion.java | 14 ++ .../smithy/java/mcp/server/McpServerTest.java | 150 +++++++++++++++++- .../java/mcp/server/ProtocolVersionTest.java | 8 + 5 files changed, 315 insertions(+), 26 deletions(-) diff --git a/mcp/mcp-schemas/model/main.smithy b/mcp/mcp-schemas/model/main.smithy index d0abb5dfc0..614d846a8a 100644 --- a/mcp/mcp-schemas/model/main.smithy +++ b/mcp/mcp-schemas/model/main.smithy @@ -34,6 +34,16 @@ structure JsonRpcErrorResponse { data: Document } +/// Error data for the -32022 UnsupportedProtocolVersionError (MCP 2026-07-28): names the +/// versions the server supports so the client can pick one and retry. +structure UnsupportedProtocolVersionErrorData { + @required + supported: StringList + + @required + requested: String +} + @private @length(min: 1) string NonEmptyString @@ -83,6 +93,30 @@ structure ServerInfo { version: String } +/// Result of the `server/discover` request (MCP 2026-07-28): advertises the protocol versions +/// the server supports, its capabilities, and (in `_meta`) its identity. +structure DiscoverResult { + @required + resultType: String = "complete" + + @required + supportedVersions: StringList + + @required + capabilities: Capabilities + + @required + ttlMs: Long = 0 + + @required + cacheScope: String = "private" + + instructions: String + + @jsonName("_meta") + meta: Document +} + structure ListToolsResult { tools: ToolInfoList } diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java index 6d7fd679f7..791178ee3a 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpService.java @@ -42,6 +42,7 @@ import software.amazon.smithy.java.mcp.OneOfTrait; import software.amazon.smithy.java.mcp.model.CallToolResult; import software.amazon.smithy.java.mcp.model.Capabilities; +import software.amazon.smithy.java.mcp.model.DiscoverResult; import software.amazon.smithy.java.mcp.model.InitializeResult; import software.amazon.smithy.java.mcp.model.JsonArraySchema; import software.amazon.smithy.java.mcp.model.JsonDocumentSchema; @@ -61,6 +62,7 @@ import software.amazon.smithy.java.mcp.model.ToolAnnotations; import software.amazon.smithy.java.mcp.model.ToolInfo; import software.amazon.smithy.java.mcp.model.Tools; +import software.amazon.smithy.java.mcp.model.UnsupportedProtocolVersionErrorData; import software.amazon.smithy.java.server.Operation; import software.amazon.smithy.java.server.Service; import software.amazon.smithy.model.shapes.ShapeId; @@ -78,6 +80,9 @@ public final class McpService { private static final InternalLogger LOG = InternalLogger.getLogger(McpService.class); private static final Context.Key ASYNC_DISPATCH = Context.key("mcp.asyncDispatch"); private static final int METHOD_NOT_FOUND_ERROR_CODE = -32601; + private static final int UNSUPPORTED_PROTOCOL_VERSION_ERROR_CODE = -32022; + private static final String PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; + private static final String SERVER_INFO_META_KEY = "io.modelcontextprotocol/serverInfo"; private static final JsonCodec CODEC = JsonCodec.builder() .settings(JsonSettings.builder() @@ -133,7 +138,9 @@ public final class McpService { *
  • Asynchronous (callback): For proxy tool calls, returns {@code null} and the callback * is invoked when the proxy responds.
  • *
  • Neither: For notifications, returns {@code null} and the callback is never - * invoked. Requests with unknown methods receive a -32601 (Method not found) error.
  • + * invoked. Requests with unknown methods receive a -32601 (Method not found) error, and + * requests naming an unsupported protocol version in {@code params._meta} receive a + * -32022 (Unsupported protocol version) error. * * * @param req The JSON-RPC request to handle @@ -162,21 +169,25 @@ public JsonRpcResponse handleRequest( // Dispatch validate(currentReq); var method = currentReq.getMethod(); - response = switch (method) { - case "initialize" -> handleInitialize(currentReq); - case "ping" -> handlePing(currentReq); - default -> { - initializeProxies(rpcResponse -> {}); - yield switch (method) { - case "prompts/list" -> handlePromptsList(currentReq); - case "prompts/get" -> handlePromptsGet(currentReq); - case "tools/list" -> handleToolsList(currentReq, protocolVersion); - case "tools/call" -> - handleToolsCall(currentReq, asyncResponseCallback, protocolVersion, hook); - default -> methodNotFound(currentReq); - }; - } - }; + response = unsupportedProtocolVersion(method, currentReq); + if (response == null) { + response = switch (method) { + case "initialize" -> handleInitialize(currentReq); + case "ping" -> handlePing(currentReq); + case "server/discover" -> handleServerDiscover(currentReq); + default -> { + initializeProxies(rpcResponse -> {}); + yield switch (method) { + case "prompts/list" -> handlePromptsList(currentReq); + case "prompts/get" -> handlePromptsGet(currentReq); + case "tools/list" -> handleToolsList(currentReq, protocolVersion); + case "tools/call" -> + handleToolsCall(currentReq, asyncResponseCallback, protocolVersion, hook); + default -> methodNotFound(currentReq); + }; + } + }; + } if (Boolean.TRUE.equals(hook.context().get(ASYNC_DISPATCH))) { return null; } @@ -201,9 +212,14 @@ private JsonRpcResponse handleRequestDirect( try { validate(req); var method = req.getMethod(); + var versionError = unsupportedProtocolVersion(method, req); + if (versionError != null) { + return versionError; + } return switch (method) { case "initialize" -> handleInitialize(req); case "ping" -> handlePing(req); + case "server/discover" -> handleServerDiscover(req); default -> { initializeProxies(rpcResponse -> {}); yield switch (method) { @@ -311,14 +327,8 @@ private JsonRpcResponse handleInitialize(JsonRpcRequest req) { } var result = builder - .capabilities(Capabilities.builder() - .tools(Tools.builder().listChanged(true).build()) - .prompts(Prompts.builder().listChanged(true).build()) - .build()) - .serverInfo(ServerInfo.builder() - .name(serviceName) - .version(version) - .build()) + .capabilities(serverCapabilities()) + .serverInfo(serverInfo()) .build(); return createSuccessResponse(req.getId(), result); @@ -332,6 +342,83 @@ private JsonRpcResponse handlePing(JsonRpcRequest req) { .build(); } + /** + * Handles {@code server/discover} (MCP 2026-07-28), which servers must implement to advertise + * their supported protocol versions, capabilities, and identity. As the negotiation bootstrap + * it is answered regardless of the protocol version the request carries. Until the modern + * (2026-07-28) era is fully supported, the advertised versions are all handshake-era — + * an explicit legacy advertisement that dual-era clients answer by falling back to the + * {@code initialize} handshake. + */ + private JsonRpcResponse handleServerDiscover(JsonRpcRequest req) { + var result = DiscoverResult.builder() + .supportedVersions(ProtocolVersion.supportedIdentifiers()) + .capabilities(serverCapabilities()) + .meta(Document.of(Map.of(SERVER_INFO_META_KEY, Document.of(serverInfo())))) + .build(); + return createSuccessResponse(req.getId(), result); + } + + private static Capabilities serverCapabilities() { + return Capabilities.builder() + .tools(Tools.builder().listChanged(true).build()) + .prompts(Prompts.builder().listChanged(true).build()) + .build(); + } + + private ServerInfo serverInfo() { + return ServerInfo.builder() + .name(serviceName) + .version(version) + .build(); + } + + /** + * Per MCP 2026-07-28, a request that names a protocol version this server does not implement + * (via the {@code io.modelcontextprotocol/protocolVersion} key of {@code params._meta}) must + * receive an UnsupportedProtocolVersionError (-32022) listing the versions the server does + * support. Returns {@code null} when the request may proceed: the method is a negotiation + * bootstrap ({@code initialize}, {@code server/discover}), no version was requested, the + * version is supported, or the message is a notification (JSON-RPC forbids responding). + */ + private static JsonRpcResponse unsupportedProtocolVersion(String method, JsonRpcRequest req) { + if ("initialize".equals(method) || "server/discover".equals(method)) { + return null; + } + var params = req.getParams(); + if (params == null) { + return null; + } + var meta = params.getMember("_meta"); + if (meta == null) { + return null; + } + var requestedMember = meta.getMember(PROTOCOL_VERSION_META_KEY); + if (requestedMember == null || !requestedMember.isType(ShapeType.STRING)) { + return null; + } + var requested = requestedMember.asString(); + if (!(ProtocolVersion.version(requested) instanceof ProtocolVersion.UnknownVersion)) { + return null; + } + if (req.getId() == null) { + return null; + } + var error = JsonRpcErrorResponse.builder() + .code(UNSUPPORTED_PROTOCOL_VERSION_ERROR_CODE) + .message("Unsupported protocol version") + .data(Document.of(UnsupportedProtocolVersionErrorData.builder() + .supported(ProtocolVersion.supportedIdentifiers()) + .requested(requested) + .build())) + .build(); + return JsonRpcResponse.builder() + .id(req.getId()) + .error(error) + .jsonrpc("2.0") + .build(); + } + private JsonRpcResponse handlePromptsList(JsonRpcRequest req) { var result = ListPromptsResult.builder() .prompts(prompts.values().stream().map(Prompt::promptInfo).toList()) diff --git a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/ProtocolVersion.java b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/ProtocolVersion.java index f5b8a62543..732ef5fd18 100644 --- a/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/ProtocolVersion.java +++ b/mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/ProtocolVersion.java @@ -6,6 +6,7 @@ package software.amazon.smithy.java.mcp.server; import java.util.Collections; +import java.util.Comparator; import java.util.List; import software.amazon.smithy.utils.SmithyUnstableApi; @@ -63,6 +64,10 @@ private static final class SupportedVersions { v2025_06_18.INSTANCE, v2025_11_25.INSTANCE); private static final ProtocolVersion LATEST = Collections.max(ALL); + private static final List IDENTIFIERS_NEWEST_FIRST = ALL.stream() + .sorted(Comparator.reverseOrder()) + .map(ProtocolVersion::identifier) + .toList(); } private final String identifier; @@ -110,4 +115,13 @@ public static ProtocolVersion defaultVersion() { public static ProtocolVersion latestVersion() { return SupportedVersions.LATEST; } + + /** + * Identifiers of every protocol version this server supports, newest first — the order + * clients expect in {@code server/discover} results and in the {@code supported} list of + * UnsupportedProtocolVersionError data. + */ + public static List supportedIdentifiers() { + return SupportedVersions.IDENTIFIERS_NEWEST_FIRST; + } } diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java index 37ee6a2f81..2c239a15c0 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java @@ -803,9 +803,9 @@ void testUnknownMethodReturnsMethodNotFound() { assertTrue(response.getError().getMessage().contains("nonexistent/method")); // String ids must be echoed back with their original type - write("server/discover", Document.of(Map.of()), Document.of("discover-1")); + write("server/does-not-exist", Document.of(Map.of()), Document.of("unknown-1")); response = read(); - assertEquals("discover-1", response.getId().asString()); + assertEquals("unknown-1", response.getId().asString()); assertNull(response.getResult()); assertEquals(-32601, response.getError().getCode()); @@ -848,6 +848,152 @@ void testUnknownNotificationIsSilentlyDropped() { assertNotNull(response.getResult()); } + @Test + void testServerDiscoverReturnsDiscoverResult() { + server = McpServer.builder() + .name("smithy-mcp-server") + .input(input) + .output(output) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + write("server/discover", modernMeta("2026-07-28"), Document.of("discover-1")); + var response = read(); + assertEquals("2.0", response.getJsonrpc()); + assertEquals("discover-1", response.getId().asString()); + assertNull(response.getError()); + + var result = response.getResult().asStringMap(); + assertEquals("complete", result.get("resultType").asString()); + assertEquals(0, result.get("ttlMs").asNumber().longValue()); + assertEquals("private", result.get("cacheScope").asString()); + + // Newest first, so a client picks the most recent mutually supported version + var versions = result.get("supportedVersions").asList().stream().map(Document::asString).toList(); + assertEquals(List.of("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"), versions); + + var capabilities = result.get("capabilities").asStringMap(); + assertTrue(capabilities.get("tools").asStringMap().get("listChanged").asBoolean()); + assertTrue(capabilities.get("prompts").asStringMap().get("listChanged").asBoolean()); + + var serverInfo = result.get("_meta") + .asStringMap() + .get("io.modelcontextprotocol/serverInfo") + .asStringMap(); + assertEquals("smithy-mcp-server", serverInfo.get("name").asString()); + assertEquals("1.0.0", serverInfo.get("version").asString()); + } + + @Test + void testServerDiscoverIsAnsweredWithoutMeta() { + server = McpServer.builder() + .name("smithy-mcp-server") + .input(input) + .output(output) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + // Discover is the negotiation bootstrap: it answers with or without the modern envelope + write("server/discover", Document.of(Map.of()), Document.of(11)); + var response = read(); + assertEquals(11, response.getId().asNumber().intValue()); + assertNull(response.getError()); + assertNotNull(response.getResult().getMember("supportedVersions")); + } + + @Test + void testUnsupportedProtocolVersionReturnsError() { + server = McpServer.builder() + .name("smithy-mcp-server") + .input(input) + .output(output) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + write("tools/list", modernMeta("2026-07-28"), Document.of(21)); + var response = read(); + assertEquals("2.0", response.getJsonrpc()); + assertEquals(21, response.getId().asNumber().intValue()); + assertNull(response.getResult()); + assertEquals(-32022, response.getError().getCode()); + assertEquals("Unsupported protocol version", response.getError().getMessage()); + + var data = response.getError().getData().asStringMap(); + assertEquals("2026-07-28", data.get("requested").asString()); + var supported = data.get("supported").asList().stream().map(Document::asString).toList(); + assertEquals(List.of("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"), supported); + + // The version rung fires before the method rung, mirroring the reference implementation + write("no/such/method", modernMeta("2026-07-28"), Document.of(22)); + response = read(); + assertEquals(-32022, response.getError().getCode()); + + // A supported version in _meta is served normally + write("tools/list", modernMeta("2025-11-25"), Document.of(23)); + response = read(); + assertEquals(23, response.getId().asNumber().intValue()); + assertNull(response.getError()); + assertNotNull(response.getResult().getMember("tools")); + } + + @Test + void testUnsupportedProtocolVersionNotificationIsDropped() { + server = McpServer.builder() + .name("smithy-mcp-server") + .input(input) + .output(output) + .addService("test-mcp", + ProxyService.builder() + .service(ShapeId.from("smithy.test#TestService")) + .proxyEndpoint("http://localhost") + .model(MODEL) + .build()) + .build(); + + server.start(); + + // JSON-RPC forbids responding to notifications, even with an unsupported version + writeNotification("notifications/does-not-exist", modernMeta("2026-07-28")); + output.assertNoOutput(); + + write("ping", Document.of(Map.of()), Document.of(31)); + var response = read(); + assertEquals(31, response.getId().asNumber().intValue()); + assertNotNull(response.getResult()); + } + + private static Document modernMeta(String version) { + return Document.of(Map.of("_meta", + Document.of(Map.of( + "io.modelcontextprotocol/protocolVersion", + Document.of(version), + "io.modelcontextprotocol/clientInfo", + Document.of(Map.of("name", Document.of("test-client"), "version", Document.of("0.0.1"))), + "io.modelcontextprotocol/clientCapabilities", + Document.of(Map.of()))))); + } + @Test void testPromptsList() { server = McpServer.builder() diff --git a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/ProtocolVersionTest.java b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/ProtocolVersionTest.java index 5d08c0596d..a1aecbc85e 100644 --- a/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/ProtocolVersionTest.java +++ b/mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/ProtocolVersionTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import org.junit.jupiter.api.Test; class ProtocolVersionTest { @@ -45,6 +46,13 @@ void latestVersionIs2025_11_25() { assertEquals("2025-11-25", ProtocolVersion.latestVersion().identifier()); } + @Test + void supportedIdentifiersAreNewestFirst() { + assertEquals( + List.of("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"), + ProtocolVersion.supportedIdentifiers()); + } + @Test void compareToOrdersChronologically() { assertTrue(ProtocolVersion.v2024_11_05.INSTANCE.compareTo(ProtocolVersion.v2025_03_26.INSTANCE) < 0);