From 8751ad453360d330bdaa1809a0c72a682c69d432 Mon Sep 17 00:00:00 2001 From: Param Parikh Date: Thu, 27 Aug 2026 23:06:03 +0000 Subject: [PATCH] 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()