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
commit 79dee237aa11a8249b63be62c3f8a19fb210e187 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Sep 8 17:17:37 2026 +0200 CAMEL-24656: camel-jbang TUI - expose the status document as MCP resources and a tui_get_status tool camel-cli-connector writes a full status document per integration to ~/.camel/<pid>-status.json; the tabs only show a digest of it. Add StatusFileReader and expose the document through the TUI MCP server as resources (camel://status/<pid> for the whole document, camel://status/<pid>/<section> for one top-level section, with resources/list, resources/read and a URI template) so an external agent can fetch exactly the section it needs. The F8 agent loop only speaks tools, so add tui_get_status(section, pid) to the core set; section='sections' lists what the document contains. Also add a guideline that the panel's own tool calls are recorded in the AI log, not the MCP log, since the model claimed there was no way to see them. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 18 +++ .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 3 + .../dsl/jbang/core/commands/tui/McpFacade.java | 20 +++ .../jbang/core/commands/tui/StatusFileReader.java | 86 +++++++++++++ .../dsl/jbang/core/commands/tui/TuiMcpServer.java | 137 ++++++++++++++++++++- .../jbang/core/commands/tui/TuiToolRegistry.java | 49 +++++++- .../dsl/jbang/core/commands/tui/AiPanelTest.java | 3 +- .../core/commands/tui/StatusFileReaderTest.java | 69 +++++++++++ .../commands/tui/TuiMcpServerResourcesTest.java | 78 ++++++++++++ 9 files changed, 460 insertions(+), 3 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index b5716f7eceb6..8039f5133e04 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -872,6 +872,24 @@ The server is bound to `127.0.0.1` only -- it never listens on external interfac When MCP is active, the TUI footer shows the connection status. Use *F2* -> _MCP Info_ to see server details and _MCP Log_ to view the tool call history. +=== Status document resources + +Every running integration keeps a full status document in `~/.camel/<pid>-status.json`, written +once a second by `camel-cli-connector`. The tabs show a digest of it; the MCP server exposes the +whole document as resources so an agent can fetch exactly the section it needs instead of +paging through tabs: + +* `camel://status/<pid>` -- the whole document +* `camel://status/<pid>/<section>` -- one top-level section, for example `context` (name, version, + state, uptime, start timestamp, statistics), `runtime`, `routes`, `endpoints`, `healthChecks`, + `properties`, `main-configuration`, `routeController`, `memory`, `threads`, `gc` or `events` + +`resources/list` enumerates the document and its sections for every monitored integration, and a +`resources/templates/list` template describes the URI shape. The same data is available to the +F8 AI panel (and as an MCP tool) through `tui_get_status`, which takes a `section` argument and +returns that section only; pass `sections` to list what the document contains. Sections such as +`events` can be large, so ask for the one you need. + === AI panel slash commands When the AI panel is open, input that starts with `/` runs a local panel command instead of sending a question to the configured AI provider. `/provider` and `/model` are unavailable while a response or command is already in progress; the panel shows a message asking you to wait. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java index 0ec870f14ef1..a640ec44cc02 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -1787,6 +1787,9 @@ class AiPanel { sb.append("- Call tui_get_options only when unsure which tab holds the data you need\n"); sb.append("- Use tui_get_state to learn which integration and tab is selected, if the question depends on it\n"); sb.append("- Use tui_get_processor_detail to explain what a route does or how its steps are configured\n"); + sb.append("- Use tui_get_status for data no tab shows (context, runtime, health checks, properties, ...)\n"); + sb.append("- Your own tool calls are recorded in the AI log (tui_get_ai_log, F2 -> AI Log); "); + sb.append("the MCP log only records external clients\n"); sb.append("- Be concise and actionable in your answers\n"); sb.append("- When something looks wrong, explain what it means and suggest fixes\n"); sb.append("- For stopping routes or applications, use tui_control for graceful shutdown\n"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index 3fc3a2c2f016..a39bcf1666bd 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -107,6 +107,7 @@ class McpFacade { private final MonitorBridge bridge; private volatile Supplier<List<AiPanel.LogEntry>> aiActivityLog; + private volatile StatusFileReader statusFiles = StatusFileReader.defaultReader(); private volatile Supplier<List<TuiMcpServer.LogEntry>> mcpActivityLog; private volatile Supplier<Integer> mcpToolCallCount; @@ -340,6 +341,25 @@ class McpFacade { return tab != null ? tab.isDetailFocused() : null; } + /** + * Reader for the per-process status documents; replaceable so tests can point it at a temporary directory. + */ + StatusFileReader statusFiles() { + return statusFiles; + } + + void setStatusFiles(StatusFileReader statusFiles) { + this.statusFiles = statusFiles; + } + + /** + * Integrations currently monitored (vanished processes excluded), for callers that need pid and name. + */ + List<IntegrationInfo> liveIntegrations() { + List<IntegrationInfo> all = data.get(); + return all == null ? List.of() : all.stream().filter(i -> !i.vanishing).toList(); + } + List<String> getIntegrationNames() { return data.get().stream() .filter(i -> !i.vanishing) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReader.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReader.java new file mode 100644 index 000000000000..906ebaafc919 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReader.java @@ -0,0 +1,86 @@ +/* + * 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.tui; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Reads the full status document that camel-cli-connector writes for each running integration to + * {@code ~/.camel/<pid>-status.json}. The tabs show a digest of it; the AI tools and MCP resources hand out whole + * top-level sections so a model can fetch exactly the piece it needs. + */ +final class StatusFileReader { + + static final String SECTION_LIST = "sections"; + + private final Path camelDir; + + StatusFileReader(Path camelDir) { + this.camelDir = camelDir; + } + + static StatusFileReader defaultReader() { + return new StatusFileReader(CommandLineHelper.getCamelDir()); + } + + Path statusFile(String pid) { + return camelDir.resolve(pid + "-status.json"); + } + + /** + * The parsed document, or {@code null} when there is no readable status file for the pid. + */ + JsonObject read(String pid) { + if (pid == null || pid.isBlank()) { + return null; + } + Path file = statusFile(pid.trim()); + if (!Files.isRegularFile(file)) { + return null; + } + try { + String text = Files.readString(file, StandardCharsets.UTF_8); + return (JsonObject) Jsoner.deserialize(text); + } catch (Exception e) { + // the connector rewrites the file every second, a torn read is expected now and then + return null; + } + } + + /** + * Top-level section names of the document, in file order, or an empty list when it cannot be read. + */ + List<String> sections(String pid) { + JsonObject doc = read(pid); + return doc == null ? List.of() : List.copyOf(doc.keySet()); + } + + /** + * One top-level section, or {@code null} when the document or the section does not exist. + */ + Object section(String pid, String section) { + JsonObject doc = read(pid); + return doc == null || section == null ? null : doc.get(section); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java index 82fc6eed794c..25edf336c4c5 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java @@ -177,11 +177,16 @@ class TuiMcpServer { case "initialize" -> handleInitialize(request); case "tools/list" -> handleToolsList(); case "tools/call" -> handleToolsCall(request); + case "resources/list" -> handleResourcesList(); + case "resources/templates/list" -> handleResourceTemplatesList(); + case "resources/read" -> handleResourcesRead(request); case "ping" -> new JsonObject(); default -> null; }; - if (result == null) { + if (result == null && "resources/read".equals(jsonrpcMethod)) { + sendError(exchange, request, -32002, "Resource not found: " + requestedUri(request)); + } else if (result == null) { sendError(exchange, request, -32601, "Method not found: " + jsonrpcMethod); } else { String responseJson = sendResult(exchange, request, result); @@ -224,6 +229,9 @@ class TuiMcpServer { log(LogLevel.TOOL, "Tool call: " + toolName, requestBody, responseBody); } case "tools/list" -> log(LogLevel.INFO, "Tools listed", requestBody, responseBody); + case "resources/list" -> log(LogLevel.INFO, "Resources listed", requestBody, responseBody); + case "resources/templates/list" -> log(LogLevel.INFO, "Resource templates listed", requestBody, responseBody); + case "resources/read" -> log(LogLevel.TOOL, "Resource read: " + requestedUri(request), requestBody, responseBody); case "ping" -> log(LogLevel.INFO, "Ping", requestBody, responseBody); default -> log(LogLevel.INFO, jsonrpcMethod, requestBody, responseBody); } @@ -250,6 +258,10 @@ class TuiMcpServer { JsonObject tools = new JsonObject(); tools.put("listChanged", false); capabilities.put("tools", tools); + JsonObject resources = new JsonObject(); + resources.put("subscribe", false); + resources.put("listChanged", false); + capabilities.put("resources", resources); result.put("capabilities", capabilities); return result; @@ -269,6 +281,129 @@ class TuiMcpServer { return result; } + // ---- Resources: the per-process status document, whole or one section at a time ---- + + static final String STATUS_URI_PREFIX = "camel://status/"; + private static final String JSON_MIME = "application/json"; + + /** + * Address of a status resource: {@code camel://status/<pid>} for the whole document, or + * {@code camel://status/<pid>/<section>} for one top-level section. + */ + record StatusUri(String pid, String section) { + + String uri() { + return STATUS_URI_PREFIX + pid + (section != null ? "/" + section : ""); + } + + /** + * Parses a resource URI, or returns {@code null} when it is not a status URI. + */ + static StatusUri parse(String uri) { + if (uri == null || !uri.startsWith(STATUS_URI_PREFIX)) { + return null; + } + String rest = uri.substring(STATUS_URI_PREFIX.length()); + if (rest.isEmpty()) { + return null; + } + int slash = rest.indexOf('/'); + String pid = slash < 0 ? rest : rest.substring(0, slash); + String section = slash < 0 ? null : rest.substring(slash + 1); + if (pid.isEmpty() || !pid.chars().allMatch(Character::isDigit) + || (section != null && (section.isEmpty() || section.contains("/")))) { + return null; + } + return new StatusUri(pid, section); + } + } + + private JsonObject handleResourcesList() { + JsonObject result = new JsonObject(); + result.put("resources", buildResourceList(facade.liveIntegrations(), facade.statusFiles())); + return result; + } + + /** + * One resource per monitored integration for the whole document, followed by one per top-level section. + */ + static JsonArray buildResourceList(List<IntegrationInfo> integrations, StatusFileReader reader) { + JsonArray resources = new JsonArray(); + for (IntegrationInfo info : integrations) { + if (info.pid == null) { + continue; + } + List<String> sections = reader.sections(info.pid); + if (sections.isEmpty()) { + continue; + } + String name = info.name != null ? info.name : info.pid; + resources.add(resource(new StatusUri(info.pid, null).uri(), name + " status", + "Full status document of " + name + " (PID " + info.pid + "): " + String.join(", ", sections))); + for (String section : sections) { + resources.add(resource(new StatusUri(info.pid, section).uri(), name + " " + section, + "The '" + section + "' section of the status document of " + name + " (PID " + info.pid + ")")); + } + } + return resources; + } + + private static JsonObject resource(String uri, String name, String description) { + JsonObject resource = new JsonObject(); + resource.put("uri", uri); + resource.put("name", name); + resource.put("description", description); + resource.put("mimeType", JSON_MIME); + return resource; + } + + private JsonObject handleResourceTemplatesList() { + JsonObject template = new JsonObject(); + template.put("uriTemplate", STATUS_URI_PREFIX + "{pid}/{section}"); + template.put("name", "Integration status section"); + template.put("description", "One top-level section of the status document that camel-cli-connector writes " + + "for the integration with the given PID; omit /{section} for the whole document. " + + "Sections include context, runtime, routes, endpoints, healthChecks, properties, " + + "main-configuration, memory, threads, gc and events."); + template.put("mimeType", JSON_MIME); + JsonArray templates = new JsonArray(); + templates.add(template); + JsonObject result = new JsonObject(); + result.put("resourceTemplates", templates); + return result; + } + + /** + * Returns the resource contents, or {@code null} when the URI does not name a readable status document or section + * (the caller turns that into a "resource not found" error). + */ + private JsonObject handleResourcesRead(JsonObject request) { + StatusUri uri = StatusUri.parse(requestedUri(request)); + if (uri == null) { + return null; + } + Object payload = uri.section() == null + ? facade.statusFiles().read(uri.pid()) + : facade.statusFiles().section(uri.pid(), uri.section()); + if (payload == null) { + return null; + } + JsonObject content = new JsonObject(); + content.put("uri", uri.uri()); + content.put("mimeType", JSON_MIME); + content.put("text", Jsoner.serialize(payload)); + JsonArray contents = new JsonArray(); + contents.add(content); + JsonObject result = new JsonObject(); + result.put("contents", contents); + return result; + } + + private static String requestedUri(JsonObject request) { + JsonObject params = (JsonObject) request.get("params"); + return params != null && params.get("uri") instanceof String s ? s : null; + } + @SuppressWarnings("unchecked") private JsonObject handleToolsCall(JsonObject request) { JsonObject params = (JsonObject) request.get("params"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java index 03bbb0fc4caf..09bd8c1bcb21 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java @@ -89,7 +89,7 @@ class TuiToolRegistry { "tui_get_state", "tui_get_options", "tui_get_table", "tui_get_log", "tui_get_errors", "tui_get_diagram", "tui_get_topology", "tui_get_processor_detail", "tui_catalog_doc", "tui_get_history", "tui_get_spans", "tui_control", "tui_send_message", "tui_get_files", - "tui_get_readme", "tui_navigate", "tui_set_log_level", "tui_filter"); + "tui_get_readme", "tui_navigate", "tui_set_log_level", "tui_filter", "tui_get_status"); /** * Returns all tool definitions. The result is cached since it is immutable. @@ -119,6 +119,7 @@ class TuiToolRegistry { case "tui_get_screen" -> callGetScreen(args); case "tui_get_events" -> callGetEvents(args); case "tui_get_state" -> callGetState(); + case "tui_get_status" -> callGetStatus(args); case "tui_show_caption" -> callShowCaption(args); case "tui_navigate" -> callNavigate(args); case "tui_send_keys" -> callSendKeys(args); @@ -392,6 +393,21 @@ class TuiToolRegistry { "Tab name to get data from (e.g. 'Routes', 'Endpoints', 'Kafka'). " + "Use tui_get_options to discover available tab names. " + "If omitted, uses the active tab."))))); + tools.add(toToolDef(toolDef( + "tui_get_status", + "Returns one top-level section of the integration's full status document, the same JSON the Camel " + + "CLI reads from ~/.camel/<pid>-status.json. Use it for data the tabs do not show: " + + "'context' (name, version, state, uptime in millis, startTimestamp, statistics), " + + "'runtime' (pid, directory, java version), 'healthChecks', 'properties', " + + "'main-configuration', 'routeController', 'services', 'transformers', 'rests', " + + "'consumers', 'producers', 'endpoints', 'dataSources', 'memory', 'threads', 'gc', " + + "'classLoading', 'trace', 'events'. Pass section='sections' to list what the " + + "document contains. Sections can be large, so request only the one you need.", + Map.of("section", propDef("string", + "Top-level section name, or 'sections' to list the available names"), + "pid", propDef("string", + "Process id of the integration; defaults to the selected integration")), + List.of("section")))); tools.add(toToolDef(toolDef( "tui_action", "Invokes a TUI action by name, bypassing fragile key sequences. " @@ -1335,6 +1351,37 @@ class TuiToolRegistry { return Jsoner.serialize(data); } + private String callGetStatus(Map<String, Object> args) { + String section = args.get("section") instanceof String s ? s.trim() : ""; + if (section.isEmpty()) { + return "Error: section is required (use 'sections' to list the available names)"; + } + String pid = args.get("pid") instanceof String s && !s.isBlank() ? s.trim() : facade.getSelectedPid(); + if (pid == null || pid.isBlank()) { + return "No integration selected"; + } + StatusFileReader reader = facade.statusFiles(); + List<String> sections = reader.sections(pid); + if (sections.isEmpty()) { + return "No status document available for PID " + pid; + } + if (StatusFileReader.SECTION_LIST.equals(section)) { + JsonObject result = new JsonObject(); + result.put("pid", pid); + result.put("sections", new JsonArray(sections)); + return Jsoner.serialize(result); + } + Object value = reader.section(pid, section); + if (value == null) { + return "Unknown section '" + section + "' for PID " + pid + ". Available: " + String.join(", ", sections); + } + JsonObject result = new JsonObject(); + result.put("pid", pid); + result.put("section", section); + result.put("data", value); + return Jsoner.serialize(result); + } + private String callAction(Map<String, Object> args) { String action = (String) args.get("action"); if (action == null || action.isBlank()) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java index 51b912a2564f..ad71a7d2ba4d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java @@ -808,7 +808,8 @@ class AiPanelTest { assertTrue(panel.toolDefinitionsForTesting().stream() .allMatch(def -> TuiToolRegistry.CORE_TOOLS.contains(def.name()))); assertFalse(panel.systemPromptForTesting().contains("tui_draw_shape")); - assertTrue(panel.describeToolModeForTesting().startsWith("core (18 of ")); + assertTrue(panel.describeToolModeForTesting() + .startsWith("core (" + TuiToolRegistry.CORE_TOOLS.size() + " of ")); } @Test diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReaderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReaderTest.java new file mode 100644 index 000000000000..c75cd7166739 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusFileReaderTest.java @@ -0,0 +1,69 @@ +/* + * 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.tui; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StatusFileReaderTest { + + private static final String STATUS = """ + {"runtime":{"pid":4711,"directory":"/tmp/app"}, + "context":{"name":"timer-log","uptime":262307,"startTimestamp":1788879661524}, + "routes":[{"routeId":"route1","state":"Started"}]} + """; + + @Test + void readsSectionsInFileOrder(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("4711-status.json"), STATUS); + StatusFileReader reader = new StatusFileReader(dir); + + assertEquals(List.of("runtime", "context", "routes"), reader.sections("4711")); + } + + @Test + void returnsOneSectionOrNull(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("4711-status.json"), STATUS); + StatusFileReader reader = new StatusFileReader(dir); + + JsonObject context = (JsonObject) reader.section("4711", "context"); + assertEquals("timer-log", context.get("name")); + assertEquals(262307L, ((Number) context.get("uptime")).longValue()); + assertNull(reader.section("4711", "nope")); + assertNull(reader.section("4711", null)); + } + + @Test + void missingOrCorruptFilesReadAsAbsent(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("99-status.json"), "{ not json"); + StatusFileReader reader = new StatusFileReader(dir); + + assertNull(reader.read("4711")); + assertNull(reader.read("99")); + assertNull(reader.read(" ")); + assertTrue(reader.sections("4711").isEmpty()); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerResourcesTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerResourcesTest.java new file mode 100644 index 000000000000..9d6bf34720e8 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerResourcesTest.java @@ -0,0 +1,78 @@ +/* + * 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.tui; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class TuiMcpServerResourcesTest { + + @Test + void parsesWholeDocumentAndSectionUris() { + TuiMcpServer.StatusUri whole = TuiMcpServer.StatusUri.parse("camel://status/4711"); + assertEquals("4711", whole.pid()); + assertNull(whole.section()); + assertEquals("camel://status/4711", whole.uri()); + + TuiMcpServer.StatusUri section = TuiMcpServer.StatusUri.parse("camel://status/4711/main-configuration"); + assertEquals("4711", section.pid()); + assertEquals("main-configuration", section.section()); + assertEquals("camel://status/4711/main-configuration", section.uri()); + } + + @Test + void rejectsUrisThatAreNotStatusResources() { + assertNull(TuiMcpServer.StatusUri.parse(null)); + assertNull(TuiMcpServer.StatusUri.parse("file:///etc/passwd")); + assertNull(TuiMcpServer.StatusUri.parse("camel://status/")); + assertNull(TuiMcpServer.StatusUri.parse("camel://status/abc")); + assertNull(TuiMcpServer.StatusUri.parse("camel://status/4711/")); + assertNull(TuiMcpServer.StatusUri.parse("camel://status/4711/a/b")); + assertNull(TuiMcpServer.StatusUri.parse("camel://status/../4711")); + } + + @Test + void listsWholeDocumentThenEachSectionPerIntegration(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("4711-status.json"), + "{\"runtime\":{\"pid\":4711},\"context\":{\"name\":\"timer-log\"}}"); + IntegrationInfo running = new IntegrationInfo(); + running.pid = "4711"; + running.name = "timer-log"; + IntegrationInfo gone = new IntegrationInfo(); + gone.pid = "4712"; + gone.name = "no-file"; + + JsonArray resources = TuiMcpServer.buildResourceList(List.of(running, gone), new StatusFileReader(dir)); + + assertEquals(3, resources.size()); + JsonObject whole = (JsonObject) resources.get(0); + assertEquals("camel://status/4711", whole.get("uri")); + assertEquals("timer-log status", whole.get("name")); + assertEquals("application/json", whole.get("mimeType")); + assertEquals("camel://status/4711/runtime", ((JsonObject) resources.get(1)).get("uri")); + assertEquals("camel://status/4711/context", ((JsonObject) resources.get(2)).get("uri")); + } +}
