This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 068189477671 CAMEL-26668: camel-mcp-server - error replies on /mcp are 
JSON-RPC error responses (#29301)
068189477671 is described below

commit 0681894776715682dbbe4682918970d5592b244e
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 14:28:25 2026 +0100

    CAMEL-26668: camel-mcp-server - error replies on /mcp are JSON-RPC error 
responses (#29301)
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../VertxMcpStreamableServerTransportProvider.java |  62 +++++----
 .../server/vertx/VertxMcpErrorResponseTest.java    | 146 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   9 ++
 3 files changed, 191 insertions(+), 26 deletions(-)

diff --git 
a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
 
b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
index 35fe74c4fa07..bbf1e4060852 100644
--- 
a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
+++ 
b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpStreamableServerTransportProvider.java
@@ -18,6 +18,7 @@ package org.apache.camel.component.mcp.server.vertx;
 
 import java.time.Duration;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
@@ -32,7 +33,6 @@ import 
io.modelcontextprotocol.server.transport.HeaderAccessor;
 import io.modelcontextprotocol.server.transport.ServerHttpHeaderValidator;
 import 
io.modelcontextprotocol.server.transport.ServerTransportSecurityException;
 import io.modelcontextprotocol.spec.HttpHeaders;
-import io.modelcontextprotocol.spec.McpError;
 import io.modelcontextprotocol.spec.McpSchema;
 import io.modelcontextprotocol.spec.McpStreamableServerSession;
 import io.modelcontextprotocol.spec.McpStreamableServerTransport;
@@ -81,10 +81,6 @@ public class VertxMcpStreamableServerTransportProvider 
implements McpStreamableS
     private static final String APPLICATION_JSON = "application/json";
     private static final String TEXT_EVENT_STREAM = "text/event-stream";
     private static final String ORIGIN = "Origin";
-    // a JSON-RPC error response without an id (McpSchema.JSONRPCResponse 
requires one, so it is written as is)
-    private static final String ORIGIN_NOT_ALLOWED = "{\"jsonrpc\":\"" + 
McpSchema.JSONRPC_VERSION
-                                                     + 
"\",\"error\":{\"code\":" + McpSchema.ErrorCodes.INVALID_REQUEST
-                                                     + ",\"message\":\"Origin 
not allowed\"}}";
 
     /**
      * The origins allowed when none are configured: loopback hosts on any 
port, so browser-based tools on the same
@@ -225,7 +221,7 @@ public class VertxMcpStreamableServerTransportProvider 
implements McpStreamableS
             ctx.response()
                     .setStatusCode(e.getStatusCode())
                     .putHeader("Content-Type", APPLICATION_JSON)
-                    .end(ORIGIN_NOT_ALLOWED);
+                    .end(errorBody(McpSchema.ErrorCodes.INVALID_REQUEST, 
"Origin not allowed"));
             return;
         }
         ctx.next();
@@ -257,8 +253,8 @@ public class VertxMcpStreamableServerTransportProvider 
implements McpStreamableS
         }
         String contentType = ctx.request().getHeader("Content-Type");
         if (contentType == null || !contentType.contains(APPLICATION_JSON)) {
-            respondError(connection, ctx, 415, 
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
-                    .message("Content-Type application/json 
required").build());
+            respondError(connection, ctx, 415, 
McpSchema.ErrorCodes.INVALID_REQUEST,
+                    "Content-Type application/json required");
             return;
         }
 
@@ -275,8 +271,8 @@ public class VertxMcpStreamableServerTransportProvider 
implements McpStreamableS
         try {
             message = McpSchema.deserializeJsonRpcMessage(jsonMapper, 
ctx.body().asString());
         } catch (Exception e) {
-            respondError(connection, ctx, 400, 
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
-                    .message("Invalid message format: " + 
e.getMessage()).build());
+            LOG.debug("Invalid MCP message: {}", e.getMessage());
+            respondError(connection, ctx, 400, 
McpSchema.ErrorCodes.PARSE_ERROR, "Invalid message format");
             return;
         }
 
@@ -298,8 +294,7 @@ public class VertxMcpStreamableServerTransportProvider 
implements McpStreamableS
         }
         ManagedSession managed = sessions.get(sessionId);
         if (managed == null) {
-            respondError(connection, ctx, 404, 
McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
-                    .message("Session not found: " + sessionId).build());
+            respondError(connection, ctx, 404, 
McpSchema.ErrorCodes.INVALID_REQUEST, "Session not found");
             return;
         }
         managed.touch();
@@ -322,8 +317,7 @@ public class VertxMcpStreamableServerTransportProvider 
implements McpStreamableS
                 transport.close();
             }
         } else {
-            respondError(connection, ctx, 500,
-                    
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Unknown message 
type").build());
+            respondError(connection, ctx, 500, 
McpSchema.ErrorCodes.INVALID_REQUEST, "Unknown message type");
         }
     }
 
@@ -404,8 +398,8 @@ public class VertxMcpStreamableServerTransportProvider 
implements McpStreamableS
         }
         String sessionId = ctx.request().getHeader(HttpHeaders.MCP_SESSION_ID);
         if (sessionId == null || sessionId.isBlank()) {
-            respondError(connection, ctx, 400, 
McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND)
-                    .message("Session ID required in " + 
HttpHeaders.MCP_SESSION_ID + " header").build());
+            respondError(connection, ctx, 400, 
McpSchema.ErrorCodes.INVALID_REQUEST,
+                    "Session ID required in " + HttpHeaders.MCP_SESSION_ID + " 
header");
             return;
         }
         ManagedSession managed = sessions.get(sessionId);
@@ -555,25 +549,41 @@ public class VertxMcpStreamableServerTransportProvider 
implements McpStreamableS
         if (errors.isEmpty()) {
             return false;
         }
-        respondError(connection, ctx, 400,
-                
McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND).message(String.join("; 
", errors)).build());
+        respondError(connection, ctx, 400, 
McpSchema.ErrorCodes.INVALID_REQUEST, String.join("; ", errors));
         return true;
     }
 
-    private void respondError(Context connection, RoutingContext ctx, int 
status, McpError error) {
-        String json;
-        try {
-            json = jsonMapper.writeValueAsString(error);
-        } catch (Exception e) {
-            json = "{}";
-        }
-        String body = json;
+    /**
+     * Answers with the given HTTP status and a JSON-RPC error response 
without an id, see
+     * {@link #errorBody(int, String)}.
+     */
+    private void respondError(Context connection, RoutingContext ctx, int 
status, int code, String message) {
+        String body = errorBody(code, message);
         connection.runOnContext(v -> ctx.response()
                 .setStatusCode(status)
                 .putHeader("Content-Type", APPLICATION_JSON)
                 .end(body));
     }
 
+    /**
+     * A JSON-RPC error response without an id (the request id is not known, 
and {@link McpSchema.JSONRPCResponse}
+     * requires one).
+     */
+    private String errorBody(int code, String message) {
+        Map<String, Object> error = new LinkedHashMap<>();
+        error.put("code", code);
+        error.put("message", message);
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("jsonrpc", McpSchema.JSONRPC_VERSION);
+        response.put("error", error);
+        try {
+            return jsonMapper.writeValueAsString(response);
+        } catch (Exception e) {
+            return "{\"jsonrpc\":\"" + McpSchema.JSONRPC_VERSION + 
"\",\"error\":{\"code\":" + code
+                   + ",\"message\":\"" + message.replace("\\", 
"\\\\").replace("\"", "\\\"") + "\"}}";
+        }
+    }
+
     private void endWithStatus(Context connection, RoutingContext ctx, int 
status) {
         connection.runOnContext(v -> 
ctx.response().setStatusCode(status).end());
     }
diff --git 
a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpErrorResponseTest.java
 
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpErrorResponseTest.java
new file mode 100644
index 000000000000..1c607952a929
--- /dev/null
+++ 
b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpErrorResponseTest.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.mcp.server.vertx;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+
+import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.camel.CamelContext;
+import org.apache.camel.component.mcp.server.McpServerInfo;
+import org.apache.camel.component.platform.http.main.MainHttpServer;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The error replies of the streamable HTTP transport on {@code /mcp} are 
JSON-RPC error responses without an id.
+ */
+class VertxMcpErrorResponseTest {
+
+    private static final String ACCEPT = "application/json, text/event-stream";
+    private static final String PING = 
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}";
+
+    private static final HttpClient HTTP = 
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+
+    private static CamelContext camelContext;
+    private static VertxMcpServerEngine engine;
+    private static String url;
+
+    @BeforeAll
+    static void startServer() throws Exception {
+        int port = AvailablePortFinder.getNextAvailable();
+        camelContext = new DefaultCamelContext();
+        MainHttpServer main = new MainHttpServer();
+        main.setCamelContext(camelContext);
+        main.setHost("0.0.0.0");
+        main.setPort(port);
+        camelContext.addService(main);
+        camelContext.start();
+
+        engine = new VertxMcpServerEngine();
+        engine.setCamelContext(camelContext);
+        engine.initialize(new McpServerInfo("error-responses", "1.0", "/mcp"));
+        engine.start();
+
+        url = "http://localhost:"; + port + "/mcp";
+    }
+
+    @AfterAll
+    static void stopServer() {
+        engine.stop();
+        camelContext.stop();
+    }
+
+    @Test
+    void wrongContentType() throws Exception {
+        HttpResponse<String> response = 
send(HttpRequest.newBuilder(URI.create(url))
+                .header("Content-Type", "text/plain")
+                .header("Accept", ACCEPT)
+                .POST(HttpRequest.BodyPublishers.ofString(PING)));
+
+        assertThat(response.statusCode()).isEqualTo(415);
+        assertJsonRpcError(response, McpSchema.ErrorCodes.INVALID_REQUEST, 
"Content-Type application/json required");
+    }
+
+    @Test
+    void invalidMessage() throws Exception {
+        HttpResponse<String> response = send(post("{\"jsonrpc\":", null));
+
+        assertThat(response.statusCode()).isEqualTo(400);
+        assertJsonRpcError(response, McpSchema.ErrorCodes.PARSE_ERROR, 
"Invalid message format");
+    }
+
+    @Test
+    void missingSessionId() throws Exception {
+        HttpResponse<String> response = send(post(PING, null));
+
+        assertThat(response.statusCode()).isEqualTo(400);
+        assertJsonRpcError(response, McpSchema.ErrorCodes.INVALID_REQUEST, 
"Session ID required in Mcp-Session-Id header");
+    }
+
+    @Test
+    void unknownSession() throws Exception {
+        HttpResponse<String> response = send(post(PING, "no-such-session"));
+
+        assertThat(response.statusCode()).isEqualTo(404);
+        assertJsonRpcError(response, McpSchema.ErrorCodes.INVALID_REQUEST, 
"Session not found");
+    }
+
+    @Test
+    void deleteWithoutSessionId() throws Exception {
+        HttpResponse<String> response = 
send(HttpRequest.newBuilder(URI.create(url)).DELETE());
+
+        assertThat(response.statusCode()).isEqualTo(400);
+        assertJsonRpcError(response, McpSchema.ErrorCodes.INVALID_REQUEST, 
"Session ID required in Mcp-Session-Id header");
+    }
+
+    private static HttpRequest.Builder post(String body, String sessionId) {
+        HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
+                .header("Content-Type", "application/json")
+                .header("Accept", ACCEPT)
+                .POST(HttpRequest.BodyPublishers.ofString(body));
+        if (sessionId != null) {
+            builder.header("Mcp-Session-Id", sessionId);
+        }
+        return builder;
+    }
+
+    private static HttpResponse<String> send(HttpRequest.Builder builder) 
throws Exception {
+        return HTTP.send(builder.timeout(Duration.ofSeconds(10)).build(), 
HttpResponse.BodyHandlers.ofString());
+    }
+
+    private static void assertJsonRpcError(HttpResponse<String> response, int 
code, String message) throws Exception {
+        
assertThat(response.headers().firstValue("Content-Type")).hasValue("application/json");
+        JsonObject json = (JsonObject) Jsoner.deserialize(response.body());
+        assertThat(json).containsOnlyKeys("jsonrpc", "error");
+        assertThat(json.getString("jsonrpc")).isEqualTo("2.0");
+        JsonObject error = json.getMap("error");
+        assertThat(error).containsOnlyKeys("code", "message");
+        assertThat(error.getInteger("code")).isEqualTo(code);
+        assertThat(error.getString("message")).isEqualTo(message);
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 7cf7de5f0646..dbc8f371bfdd 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -61,6 +61,15 @@ Starting in 4.24, parameter values provided via the 
`CamelJpaParameters` header
 
 Routes that previously relied on evaluating Simple expressions within the 
`CamelJpaParameters` header should evaluate those expressions in the route 
before setting the header value.
 
+=== camel-mcp-server - error replies on /mcp are JSON-RPC error responses
+
+With the embedded HTTP server (Camel Main and Camel JBang), a request on 
`/mcp` that fails before it reaches the MCP
+session (wrong `Content-Type`, a message that cannot be parsed, a missing or 
unknown `Mcp-Session-Id`) is answered with
+the same HTTP status as before, and a body that is a JSON-RPC error response 
without an `id`:
+`{"jsonrpc":"2.0","error":{"code":-32600,"message":"Session not found"}}`. The 
messages are short and fixed. The error
+code is `-32700` (parse error) for a message that cannot be parsed, and 
`-32600` (invalid request) otherwise; before,
+a missing header or session id gave `-32601` and an unknown session `-32603`.
+
 === camel-micrometer, camel-opentelemetry-metrics - tags and attributes from 
headers are used as given
 
 The `micrometer` and `opentelemetry-metrics` producers now use the tags of the 
`CamelMetricsTags` header and the

Reply via email to