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();
+    }
+}

Reply via email to