This is an automated email from the ASF dual-hosted git repository.
gnodet 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 c954f0635c46 Extract RuntimeHelper for shared IPC code between CLI and
MCP server
c954f0635c46 is described below
commit c954f0635c46fedb37e6ea68d74946c672800866
Author: Guillaume Nodet <[email protected]>
AuthorDate: Fri May 22 11:25:20 2026 +0200
Extract RuntimeHelper for shared IPC code between CLI and MCP server
- Extract RuntimeHelper utility class in camel-jbang-core/common/ with
shared IPC methods:
process discovery, status reading, action execution (multi-client
protocol), graceful stop
- Refactor MCP RuntimeService to delegate to RuntimeHelper
- Add Javadoc with @since 4.21 tags
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
.../camel/dsl/jbang/core/common/RuntimeHelper.java | 247 +++++++++++++++++++++
.../jbang/core/commands/mcp/RuntimeService.java | 115 ++++++++++
2 files changed, 362 insertions(+)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
new file mode 100644
index 000000000000..94897399442a
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
@@ -0,0 +1,247 @@
+/*
+ * 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.common;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import org.apache.camel.support.PatternHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.StopWatch;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Shared helper for discovering running Camel processes and communicating
with them via the file-based IPC protocol.
+ * <p>
+ * Camel JBang applications write status snapshots to {@code
~/.camel/{pid}-status.json}. Actions are requested by
+ * writing to {@code {pid}-action-{requestId}.json} and reading the response
from {@code {pid}-output-{requestId}.json}.
+ * Each request gets a unique ID so concurrent callers (CLI, MCP server, etc.)
don't interfere with each other.
+ * <p>
+ * This class is used by both the {@code camel ask} CLI command and the MCP
server's {@code RuntimeService}.
+ *
+ * @since 4.21
+ */
+public final class RuntimeHelper {
+
+ private static final long ACTION_TIMEOUT_MS = 10_000;
+ private static final long POLL_INTERVAL_MS = 100;
+
+ /**
+ * Information about a discovered running Camel process.
+ *
+ * @param pid the OS process ID
+ * @param name the application name (extracted from status or
process info)
+ * @param contextName the Camel context name, or {@code null} if not
available
+ *
+ * @since 4.21
+ */
+ public record ProcessInfo(long pid, String name, String contextName) {
+ }
+
+ private RuntimeHelper() {
+ }
+
+ /**
+ * Discovers all running Camel processes by scanning {@code ~/.camel/} for
{@code {pid}-status.json} files and
+ * verifying each PID is still alive.
+ */
+ public static List<ProcessInfo> discoverProcesses() {
+ List<ProcessInfo> result = new ArrayList<>();
+ Path camelDir = CommandLineHelper.getCamelDir();
+ File dir = camelDir.toFile();
+ if (!dir.isDirectory()) {
+ return result;
+ }
+
+ File[] statusFiles = dir.listFiles((d, name) ->
name.matches("\\d+-status\\.json"));
+ if (statusFiles == null) {
+ return result;
+ }
+
+ for (File sf : statusFiles) {
+ String fileName = sf.getName();
+ long pid = Long.parseLong(fileName.substring(0,
fileName.indexOf('-')));
+ if
(!ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false)) {
+ continue;
+ }
+ try {
+ JsonObject root = readStatusFromFile(sf.toPath());
+ if (root != null) {
+ String name = ProcessHelper.extractName(root,
ProcessHandle.of(pid).orElse(null));
+ String contextName = null;
+ JsonObject context = (JsonObject) root.get("context");
+ if (context != null) {
+ contextName = context.getString("name");
+ }
+ result.add(new ProcessInfo(pid, name, contextName));
+ }
+ } catch (Exception e) {
+ // skip
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Finds a single Camel process matching the given name or PID. Returns
{@code null} if no match is found or if
+ * multiple processes match. When {@code nameOrPid} is {@code null} and
exactly one process is running, returns that
+ * process.
+ *
+ * @param nameOrPid a process name (with optional wildcard), a numeric PID
string, or {@code null} for auto-detect
+ */
+ public static ProcessInfo findProcess(String nameOrPid) {
+ List<ProcessInfo> processes = discoverProcesses();
+ if (processes.isEmpty()) {
+ return null;
+ }
+
+ if (nameOrPid != null && !nameOrPid.isBlank()) {
+ if (nameOrPid.matches("\\d+")) {
+ long pid = Long.parseLong(nameOrPid);
+ return processes.stream()
+ .filter(p -> p.pid == pid)
+ .findFirst()
+ .orElse(null);
+ }
+ String pattern = nameOrPid.endsWith("*") ? nameOrPid : nameOrPid +
"*";
+ List<ProcessInfo> matched = processes.stream()
+ .filter(p -> (p.name != null &&
PatternHelper.matchPattern(FileUtil.onlyName(p.name), pattern))
+ || (p.contextName != null &&
PatternHelper.matchPattern(p.contextName, pattern)))
+ .toList();
+ if (matched.size() == 1) {
+ return matched.get(0);
+ }
+ return null;
+ }
+
+ if (processes.size() == 1) {
+ return processes.get(0);
+ }
+ return null;
+ }
+
+ /**
+ * Reads the full status JSON for the given process.
+ *
+ * @return the parsed status object, or {@code null} if the status file
does not exist or is unreadable
+ */
+ public static JsonObject readStatus(long pid) {
+ Path statusFile = CommandLineHelper.getCamelDir().resolve(pid +
"-status.json");
+ return readStatusFromFile(statusFile);
+ }
+
+ /**
+ * Reads a specific section from the status JSON.
+ *
+ * @param section the top-level key to extract (e.g., "context",
"routes", "health")
+ * @return the section value as a JSON string, or {@code "{}"} if
absent
+ */
+ public static String readStatusSection(long pid, String section) {
+ JsonObject root = readStatus(pid);
+ if (root == null) {
+ return "No status available for PID " + pid;
+ }
+ Object value = root.get(section);
+ if (value instanceof JsonObject jo) {
+ return jo.toJson();
+ }
+ if (value != null) {
+ JsonObject wrapper = new JsonObject();
+ wrapper.put(section, value);
+ return wrapper.toJson();
+ }
+ return "{}";
+ }
+
+ /**
+ * Executes an action against a running Camel process using the file-based
IPC protocol. Writes an action request
+ * file and polls for the output file within a timeout.
+ *
+ * @param pid the target process ID
+ * @param action the action name (e.g., "route", "top", "source")
+ * @param configure optional callback to add extra fields to the request
JSON
+ * @return the raw response string, or a timeout message if no
response was received
+ */
+ public static String executeAction(long pid, String action,
Consumer<JsonObject> configure) {
+ String requestId = UUID.randomUUID().toString().substring(0, 8);
+ Path camelDir = CommandLineHelper.getCamelDir();
+ Path outputFile = camelDir.resolve(pid + "-output-" + requestId +
".json");
+ PathUtils.deleteFile(outputFile);
+
+ JsonObject root = new JsonObject();
+ root.put("action", action);
+ if (configure != null) {
+ configure.accept(root);
+ }
+
+ Path actionFile = camelDir.resolve(pid + "-action-" + requestId +
".json");
+ PathUtils.writeTextSafely(root.toJson(), actionFile);
+
+ try {
+ StopWatch watch = new StopWatch();
+ while (watch.taken() < ACTION_TIMEOUT_MS) {
+ try {
+ Thread.sleep(POLL_INTERVAL_MS);
+ if (Files.exists(outputFile) &&
outputFile.toFile().length() > 0) {
+ return Files.readString(outputFile);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (Exception e) {
+ // retry
+ }
+ }
+ return "Timeout waiting for response from PID " + pid + " for
action: " + action;
+ } finally {
+ PathUtils.deleteFile(outputFile);
+ PathUtils.deleteFile(actionFile);
+ }
+ }
+
+ /**
+ * Initiates a graceful shutdown of a running Camel application by
deleting its PID file.
+ */
+ public static String stopApplication(long pid) {
+ Path pidFile =
CommandLineHelper.getCamelDir().resolve(Long.toString(pid));
+ if (Files.exists(pidFile)) {
+ PathUtils.deleteFile(pidFile);
+ return "Graceful shutdown initiated for PID " + pid
+ + ". The application will finish processing in-flight
exchanges and shut down.";
+ } else {
+ return "PID file not found for " + pid + ". The process may
already be stopping.";
+ }
+ }
+
+ public static JsonObject readStatusFromFile(Path path) {
+ try {
+ if (Files.exists(path) && path.toFile().length() > 0) {
+ String text = Files.readString(path);
+ return (JsonObject) Jsoner.deserialize(text);
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return null;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeService.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeService.java
new file mode 100644
index 000000000000..7d349268a836
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeService.java
@@ -0,0 +1,115 @@
+/*
+ * 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.mcp;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import io.quarkiverse.mcp.server.ToolCallException;
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * CDI service for discovering running Camel processes and communicating with
them via the file-based IPC protocol.
+ * <p>
+ * This is a thin wrapper around {@link RuntimeHelper} that translates null
returns and error conditions into
+ * {@link ToolCallException} instances suitable for MCP tool responses.
+ *
+ * @since 4.21
+ */
+@ApplicationScoped
+public class RuntimeService {
+
+ /**
+ * Process information exposed to MCP tools.
+ *
+ * @since 4.21
+ */
+ public record ProcessInfo(long pid, String name, String contextName) {
+ }
+
+ public List<ProcessInfo> discoverProcesses() {
+ return RuntimeHelper.discoverProcesses().stream()
+ .map(p -> new ProcessInfo(p.pid(), p.name(), p.contextName()))
+ .toList();
+ }
+
+ public ProcessInfo findSingleProcess(String nameOrPid) {
+ List<RuntimeHelper.ProcessInfo> processes =
RuntimeHelper.discoverProcesses();
+
+ if (processes.isEmpty()) {
+ throw new ToolCallException("No running Camel processes found",
null);
+ }
+
+ RuntimeHelper.ProcessInfo found = RuntimeHelper.findProcess(nameOrPid);
+ if (found != null) {
+ return new ProcessInfo(found.pid(), found.name(),
found.contextName());
+ }
+
+ if (nameOrPid != null && !nameOrPid.isBlank()) {
+ throw new ToolCallException(
+ "No unique Camel process found matching '" + nameOrPid +
"': "
+ + processes.stream().map(p -> p.name()
+ " (PID " + p.pid() + ")").toList()
+ + ". Specify a more specific name or
PID.",
+ null);
+ }
+
+ throw new ToolCallException(
+ "Multiple Camel processes running: "
+ + processes.stream().map(p -> p.name() + "
(PID " + p.pid() + ")").toList()
+ + ". Specify nameOrPid to select one.",
+ null);
+ }
+
+ public JsonObject readStatus(long pid) {
+ return RuntimeHelper.readStatus(pid);
+ }
+
+ public JsonObject readStatusSection(long pid, String section) {
+ JsonObject root = RuntimeHelper.readStatus(pid);
+ if (root == null) {
+ throw new ToolCallException("No status available for PID " + pid,
null);
+ }
+ Object value = root.get(section);
+ if (value instanceof JsonObject jo) {
+ return jo;
+ }
+ if (value != null) {
+ JsonObject wrapper = new JsonObject();
+ wrapper.put(section, value);
+ return wrapper;
+ }
+ return new JsonObject();
+ }
+
+ public JsonObject executeAction(long pid, String action,
Consumer<JsonObject> configure) {
+ String result = RuntimeHelper.executeAction(pid, action, configure);
+ if (result != null && result.startsWith("Timeout")) {
+ throw new ToolCallException(result, null);
+ }
+ try {
+ return (JsonObject) Jsoner.deserialize(result);
+ } catch (Exception e) {
+ JsonObject wrapper = new JsonObject();
+ wrapper.put("result", result);
+ return wrapper;
+ }
+ }
+}