diff --git a/mcp/mcp-schemas/model/main.smithy b/mcp/mcp-schemas/model/main.smithy index d0abb5dfc..614d846a8 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 04e15fb91..791178ee3 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) { @@ -296,7 +312,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(); } } @@ -307,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); @@ -328,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 555559a3b..732ef5fd1 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,9 @@ 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; @SmithyUnstableApi @@ -49,6 +52,24 @@ 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 static final List IDENTIFIERS_NEWEST_FIRST = ALL.stream() + .sorted(Comparator.reverseOrder()) + .map(ProtocolVersion::identifier) + .toList(); + } + private final String identifier; private ProtocolVersion(String identifier) { @@ -72,17 +93,35 @@ 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; + } + + /** + * 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 88c7abfe6..2c239a15c 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() @@ -780,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()); @@ -825,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 d95017e47..a1aecbc85 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 { @@ -39,6 +40,19 @@ 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 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);