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