This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26298-mcp-stdio-startup in repository https://gitbox.apache.org/repos/asf/camel.git
commit bb41b5df7268c9b98dec8eab89e217375808ce82 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Nov 1 20:25:21 2026 +0100 CAMEL-26298: camel-mcp-server - camel run --mcp-stdio starts, and stdout carries the MCP protocol only The stdio engine looked up the MCP JSON mapper in initialize(), while Camel Main configures the CamelContext. The MCP SDK finds it with the service loader of the thread context class loader, which in Camel CLI cannot see the downloaded mcp-json-jackson2 jar then (it is only the application class loader of CamelContext while the context starts), so camel run --mcp-stdio failed with "No McpJsonMapperSupplier available". The mapper is now looked up with the application context class loader of CamelContext, when the engine needs it. Also for stdio mode: - System.out points at stderr while the engine runs and the transport writes to the original stdout, so a stream:out endpoint or a println in a bean does not break the MCP stream - the engine stops the CamelContext when the client closes stdin, so the process exits - camel run does not print the configuration values of camel config to stdout with --mcp-stdio Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../org/apache/camel/catalog/docs/mcp-server.adoc | 11 ++- .../camel-mcp-server/src/main/docs/mcp-server.adoc | 11 ++- .../mcp/server/stdio/StdioMcpServerEngine.java | 95 ++++++++++++++++++++-- .../mcp/server/stdio/McpStdioHarnessMain.java | 38 +++++++++ .../mcp/server/stdio/StdioMcpServerProcessIT.java | 64 ++++++++++++++- .../apache/camel/dsl/jbang/core/commands/Run.java | 9 +- .../commands/RunMcpStdioConfigurationTest.java | 58 +++++++++++++ 7 files changed, 269 insertions(+), 17 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc index 8a4e562991e9..e7f46982a45f 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mcp-server.adoc @@ -220,7 +220,10 @@ JSON-RPC only. Camel JBang configures a stderr Log4j2 appender automatically when `--mcp-stdio` is used or when `camel.server.mcp-transport=stdio` is set in the profile. On Camel Main, configure logging to stderr yourself (for example using the `log4j2-mcp-stdio.properties` resource shipped in -`camel-mcp-server`). Configure your MCP client (for example in `mcp.json`) to launch +`camel-mcp-server`). Anything else written to `System.out` while the MCP server runs, such as +a `stream:out` endpoint or a `System.out.println` in a bean, goes to stderr as well. +When the MCP client closes stdin, Camel stops and the process exits. +Configure your MCP client (for example in `mcp.json`) to launch the Camel process as a subprocess: [source,json] @@ -346,8 +349,10 @@ subscriptions (`resources/subscribe`) are not offered: the server advertises The stdio engine uses the MCP Java SDK `StdioServerTransportProvider`. MCP JSON-RPC is read from stdin and written to stdout. Logging and startup banners must not appear on stdout — Camel Main/JBang configure Log4j2 to write logs to -stderr in stdio mode. No HTTP port is opened and no session management headers -apply. +stderr in stdio mode. While the engine runs, `System.out` points at stderr, and the +transport writes to the original stdout. When stdin reaches its end (the client went +away), the engine stops the CamelContext. No HTTP port is opened and no session +management headers apply. == Security diff --git a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc index 8a4e562991e9..e7f46982a45f 100644 --- a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc +++ b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc @@ -220,7 +220,10 @@ JSON-RPC only. Camel JBang configures a stderr Log4j2 appender automatically when `--mcp-stdio` is used or when `camel.server.mcp-transport=stdio` is set in the profile. On Camel Main, configure logging to stderr yourself (for example using the `log4j2-mcp-stdio.properties` resource shipped in -`camel-mcp-server`). Configure your MCP client (for example in `mcp.json`) to launch +`camel-mcp-server`). Anything else written to `System.out` while the MCP server runs, such as +a `stream:out` endpoint or a `System.out.println` in a bean, goes to stderr as well. +When the MCP client closes stdin, Camel stops and the process exits. +Configure your MCP client (for example in `mcp.json`) to launch the Camel process as a subprocess: [source,json] @@ -346,8 +349,10 @@ subscriptions (`resources/subscribe`) are not offered: the server advertises The stdio engine uses the MCP Java SDK `StdioServerTransportProvider`. MCP JSON-RPC is read from stdin and written to stdout. Logging and startup banners must not appear on stdout — Camel Main/JBang configure Log4j2 to write logs to -stderr in stdio mode. No HTTP port is opened and no session management headers -apply. +stderr in stdio mode. While the engine runs, `System.out` points at stderr, and the +transport writes to the original stdout. When stdin reaches its end (the client went +away), the engine stops the CamelContext. No HTTP port is opened and no session +management headers apply. == Security diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/stdio/StdioMcpServerEngine.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/stdio/StdioMcpServerEngine.java index 6db97cf5f055..b20a1e79bf17 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/stdio/StdioMcpServerEngine.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/stdio/StdioMcpServerEngine.java @@ -16,12 +16,16 @@ */ package org.apache.camel.component.mcp.server.stdio; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.PrintStream; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; @@ -67,6 +71,7 @@ public class StdioMcpServerEngine extends ServiceSupport implements McpServerEng private McpSyncServer server; private InputStream inputStream; private OutputStream outputStream; + private PrintStream originalOut; private final List<McpServerFeatures.SyncToolSpecification> pendingTools = new ArrayList<>(); @Override @@ -82,7 +87,31 @@ public class StdioMcpServerEngine extends ServiceSupport implements McpServerEng @Override public void initialize(McpServerInfo info) { this.info = info; - this.jsonMapper = McpJsonDefaults.getMapper(); + } + + /** + * The JSON mapper of the MCP SDK, found with the service loader of the application class loader of Camel. + * <p> + * The MCP SDK finds the mapper with the thread context class loader. Camel CLI (camel run) loads the downloaded + * jars, such as mcp-json-jackson2, in the application context class loader of CamelContext, which is the thread + * context class loader while CamelContext starts, but not while Camel Main configures it and initializes this + * engine. + */ + private McpJsonMapper jsonMapper() { + if (jsonMapper == null) { + ClassLoader cl = camelContext != null ? camelContext.getApplicationContextClassLoader() : null; + if (cl == null) { + cl = StdioMcpServerEngine.class.getClassLoader(); + } + ClassLoader tccl = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(cl); + jsonMapper = McpJsonDefaults.getMapper(); + } finally { + Thread.currentThread().setContextClassLoader(tccl); + } + } + return jsonMapper; } /** @@ -101,13 +130,15 @@ public class StdioMcpServerEngine extends ServiceSupport implements McpServerEng @Override protected void doStart() throws Exception { - if (jsonMapper == null) { - jsonMapper = McpJsonDefaults.getMapper(); - } + McpJsonMapper mapper = jsonMapper(); if (inputStream != null && outputStream != null) { - transport = new StdioServerTransportProvider(jsonMapper, inputStream, outputStream); + transport = new StdioServerTransportProvider(mapper, inputStream, outputStream); } else { - transport = new StdioServerTransportProvider(jsonMapper); + // stdout carries the MCP protocol only: keep it for the transport, and send anything else written to + // System.out (a stream:out endpoint, a println in a bean, a banner of a library) to stderr + originalOut = System.out; + System.setOut(System.err); + transport = new StdioServerTransportProvider(mapper, new StopOnEndOfInput(System.in), originalOut); } server = McpServer.sync(transport) .serverInfo(info.serverName(), info.version()) @@ -129,10 +160,60 @@ public class StdioMcpServerEngine extends ServiceSupport implements McpServerEng server.closeGracefully(); server = null; } + if (originalOut != null) { + System.setOut(originalOut); + originalOut = null; + } pendingTools.clear(); transport = null; } + private void onEndOfInput() { + if (camelContext == null || isStoppingOrStopped()) { + return; + } + // the client closed stdin: the MCP stdio transport expects the server to exit then, so stop Camel (which + // also ends Camel Main), from another thread as this is the thread that reads stdin + LOG.info("MCP client closed stdin, stopping {}", camelContext.getName()); + Thread thread = new Thread(() -> { + try { + camelContext.stop(); + } catch (Exception e) { + LOG.warn("Failed to stop {} after the MCP client closed stdin: {}", camelContext.getName(), e.getMessage()); + } + }, "CamelMcpStdioShutdown"); + thread.start(); + } + + /** + * Calls {@link #onEndOfInput()} when the input reaches its end. + */ + private final class StopOnEndOfInput extends FilterInputStream { + + private final AtomicBoolean ended = new AtomicBoolean(); + + StopOnEndOfInput(InputStream in) { + super(in); + } + + @Override + public int read() throws IOException { + return checkEnd(super.read()); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return checkEnd(super.read(b, off, len)); + } + + private int checkEnd(int n) { + if (n == -1 && ended.compareAndSet(false, true)) { + onEndOfInput(); + } + return n; + } + } + @Override public void toolAdded(McpServerTool tool) { McpServerFeatures.SyncToolSpecification spec = buildToolSpecification(tool); @@ -197,7 +278,7 @@ public class StdioMcpServerEngine extends ServiceSupport implements McpServerEng private McpSchema.Tool buildMcpTool(McpServerTool tool) { String schema = tool.inputSchemaJson() != null ? tool.inputSchemaJson() : EMPTY_OBJECT_SCHEMA; - McpSchema.Tool.Builder builder = McpSchema.Tool.builder(tool.name(), jsonMapper, schema) + McpSchema.Tool.Builder builder = McpSchema.Tool.builder(tool.name(), jsonMapper(), schema) .description(tool.description()); applyAnnotations(builder, tool.annotations()); return builder.build(); diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/stdio/McpStdioHarnessMain.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/stdio/McpStdioHarnessMain.java index 6c09847fae04..e84bc1530779 100644 --- a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/stdio/McpStdioHarnessMain.java +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/stdio/McpStdioHarnessMain.java @@ -16,6 +16,11 @@ */ package org.apache.camel.component.mcp.server.stdio; +import java.io.IOException; +import java.net.URL; +import java.util.Collections; +import java.util.Enumeration; + import org.apache.camel.StartupSummaryLevel; import org.apache.camel.builder.RouteBuilder; import org.apache.camel.main.Main; @@ -32,6 +37,11 @@ public final class McpStdioHarnessMain { public static void main(String[] args) throws Exception { System.setProperty("log4j2.configurationFile", "classpath:log4j2-mcp-stdio.properties"); String tags = args.length > 0 ? args[0] : "harness"; + if (args.length > 1 && "cli".equals(args[1])) { + // as Camel CLI (camel run): the thread context class loader cannot see the downloaded jars, such as + // mcp-json-jackson2, while Camel Main configures the CamelContext + Thread.currentThread().setContextClassLoader(new HidingClassLoader(McpStdioHarnessMain.class.getClassLoader())); + } Main main = new Main(); main.configure().setStartupSummaryLevel(StartupSummaryLevel.Off); @@ -46,10 +56,38 @@ public final class McpStdioHarnessMain { .routeId("say-hello-route") .setBody(simple("Hello ${header.name}")); + // writes to System.out, which in stdio mode must not reach the MCP stream + from("ai-tool:print_tool?tags=" + tags + "&description=Print to System.out") + .process(e -> System.out.println("printed by a route")) + .setBody(constant("printed")); + from("ai-tool:hidden_tool?description=Untagged tool, must not be exposed") .setBody(constant("hidden")); } }); main.run(); } + + /** + * Hides the service file of the MCP JSON mapper, as the class loader of Camel CLI does not have the downloaded + * jars. + */ + private static final class HidingClassLoader extends ClassLoader { + + private static final String HIDDEN = "META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier"; + + HidingClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + public URL getResource(String name) { + return HIDDEN.equals(name) ? null : super.getResource(name); + } + + @Override + public Enumeration<URL> getResources(String name) throws IOException { + return HIDDEN.equals(name) ? Collections.emptyEnumeration() : super.getResources(name); + } + } } diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/stdio/StdioMcpServerProcessIT.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/stdio/StdioMcpServerProcessIT.java index 261741a98c30..c5cd3e37ea0c 100644 --- a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/stdio/StdioMcpServerProcessIT.java +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/stdio/StdioMcpServerProcessIT.java @@ -19,6 +19,7 @@ package org.apache.camel.component.mcp.server.stdio; import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import io.modelcontextprotocol.client.McpClient; @@ -80,8 +81,64 @@ class StdioMcpServerProcessIT { } } + @Test + void testStdioMcpServerStartsWhenTheContextClassLoaderCannotSeeTheJsonMapper() { + McpSyncClient client = null; + try { + client = createClient("cli"); + client.initialize(); + + McpSchema.CallToolResult result + = client.callTool(new McpSchema.CallToolRequest("say_hello", Map.of("name", "Camel"))); + assertThat(textOf(result)).isEqualTo("Hello Camel"); + } finally { + if (client != null) { + client.closeGracefully(); + } + } + } + + @Test + void testStdioMcpServerKeepsSystemOutOfTheMcpStream() { + McpSyncClient client = null; + try { + client = createClient(); + client.initialize(); + + McpSchema.CallToolResult printed = client.callTool(new McpSchema.CallToolRequest("print_tool", Map.of())); + assertThat(textOf(printed)).isEqualTo("printed"); + // the printed line went to stderr, so the next call still works + McpSchema.CallToolResult result + = client.callTool(new McpSchema.CallToolRequest("say_hello", Map.of("name", "Camel"))); + assertThat(textOf(result)).isEqualTo("Hello Camel"); + } finally { + if (client != null) { + client.closeGracefully(); + } + } + } + + @Test + void testStdioMcpServerExitsWhenTheClientClosesStdin() throws Exception { + List<String> command = harnessCommand("default"); + Process process = new ProcessBuilder(command) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start(); + try { + process.getOutputStream().close(); + assertThat(process.waitFor(60, TimeUnit.SECONDS)).as("the server exits when stdin is closed").isTrue(); + } finally { + process.destroyForcibly(); + } + } + private static McpSyncClient createClient() { - List<String> command = harnessCommand(); + return createClient("default"); + } + + private static McpSyncClient createClient(String mode) { + List<String> command = harnessCommand(mode); ServerParameters parameters = ServerParameters.builder(command.get(0)) .args(command.subList(1, command.size())) .build(); @@ -91,7 +148,7 @@ class StdioMcpServerProcessIT { .build(); } - private static List<String> harnessCommand() { + private static List<String> harnessCommand(String mode) { String javaHome = System.getProperty("java.home"); String javaBin = javaHome + "/bin/java"; String classpath = System.getProperty("java.class.path"); @@ -101,7 +158,8 @@ class StdioMcpServerProcessIT { "-cp", classpath, McpStdioHarnessMain.class.getName(), - HARNESS_TAG); + HARNESS_TAG, + mode); } private static String textOf(McpSchema.CallToolResult result) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java index 29b6d27bee57..eaebca1dc2f4 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java @@ -341,11 +341,18 @@ public class Run extends CamelCommand { } checkFilesExist(); - printConfigurationValues("Running integration with the following configuration:"); + printRunConfiguration(); // run return run(); } + void printRunConfiguration() { + // the printer of this command is quiet with MCP over stdio, already for the configuration values printed + // before the run is prepared, as stdout carries the MCP protocol only + mcpStdioMode = serverOptions.mcpStdio; + printConfigurationValues("Running integration with the following configuration:"); + } + /** * Fails a local file that does not exist with exit code 66, before anything is prepared or printed. Remote files * (such as github: and https:) and wildcards are checked when they are read. diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RunMcpStdioConfigurationTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RunMcpStdioConfigurationTest.java new file mode 100644 index 000000000000..a46c214b0feb --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RunMcpStdioConfigurationTest.java @@ -0,0 +1,58 @@ +/* + * 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.dsl.jbang.core.commands; + +import org.apache.camel.dsl.jbang.core.commands.config.BaseConfigTestSupport; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.run.common.StringPrinter; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * With MCP over stdio, stdout carries the MCP protocol only, so the configuration values of camel config are not + * printed there (CAMEL-26298). + */ +class RunMcpStdioConfigurationTest extends BaseConfigTestSupport { + + @Test + void configurationValuesArePrinted() throws Exception { + UserConfigHelper.createUserConfig("dev=true"); + + assertThat(printRunConfiguration("tools.yaml")) + .contains("Running integration with the following configuration:") + .contains("--reload=true"); + } + + @Test + void configurationValuesAreNotPrintedWithMcpStdio() throws Exception { + UserConfigHelper.createUserConfig("dev=true"); + + assertThat(printRunConfiguration("tools.yaml", "--mcp-stdio", "--mcp-tags=demo")).isEmpty(); + } + + private static String printRunConfiguration(String... args) { + StringPrinter out = new StringPrinter(); + Run run = new Run(new CamelJBangMain().withPrinter(out)); + CommandLine commandLine = new CommandLine(run); + CommandLineHelper.augmentWithUserConfiguration(commandLine); + commandLine.parseArgs(args); + run.printRunConfiguration(); + return out.getStdout(); + } +}
