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 d22343b2d..04e15fb91 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 326fef7d8..88c7abfe6 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()