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 1fd5519c6ca798614742d4e2b96902fb52db89f9
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 8 17:27:34 2026 +0200

    CAMEL-24656: camel-jbang TUI - expose the integration log tail as an MCP 
resource
    
    camel://log/<pid> returns the last 200 lines of ~/.camel/<pid>.log, and
    camel://log/<pid>?lines=<n> a different tail length (up to 5000), read from
    the end of the file so a large log is never loaded whole. Listed next to the
    status resources and described by a URI template. The Log tab only shows a
    window of the file, so the template points agents at tui_get_screen when 
they
    need to know which lines the user is looking at.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |  8 +-
 .../jbang/core/commands/tui/StatusFileReader.java  | 67 ++++++++++++++++
 .../dsl/jbang/core/commands/tui/TuiMcpServer.java  | 90 ++++++++++++++++++++--
 .../core/commands/tui/StatusFileReaderTest.java    | 20 +++++
 .../commands/tui/TuiMcpServerResourcesTest.java    | 34 ++++++--
 5 files changed, 204 insertions(+), 15 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 8039f5133e04..7ab129ae3ad2 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -879,13 +879,17 @@ once a second by `camel-cli-connector`. The tabs show a 
digest of it; the MCP se
 whole document as resources so an agent can fetch exactly the section it needs 
instead of
 paging through tabs:
 
+* `camel://log/<pid>` -- the last 200 lines of the integration's log 
(`~/.camel/<pid>.log`);
+  `camel://log/<pid>?lines=<n>` for a different tail length (up to 5000). The 
Log tab shows only a
+  window of this file, so an agent that needs to know which lines the user is 
looking at should
+  use the `tui_get_screen` tool instead.
 * `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
+`resources/list` enumerates the log, the document and its sections for every 
monitored
+integration, and `resources/templates/list` describes the URI shapes. 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.
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
index 906ebaafc919..53cbbf3b4e50 100644
--- 
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
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.io.RandomAccessFile;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -33,6 +34,8 @@ import org.apache.camel.util.json.Jsoner;
 final class StatusFileReader {
 
     static final String SECTION_LIST = "sections";
+    static final int DEFAULT_LOG_LINES = 200;
+    static final int MAX_LOG_LINES = 5000;
 
     private final Path camelDir;
 
@@ -48,6 +51,70 @@ final class StatusFileReader {
         return camelDir.resolve(pid + "-status.json");
     }
 
+    Path logFile(String pid) {
+        return camelDir.resolve(pid + ".log");
+    }
+
+    boolean hasLog(String pid) {
+        return pid != null && !pid.isBlank() && 
Files.isRegularFile(logFile(pid.trim()));
+    }
+
+    /**
+     * The last {@code lines} lines of the process log ({@code 
~/.camel/<pid>.log}), read from the end of the file so a
+     * large log is not loaded whole; {@code null} when there is no log file. 
{@code lines} is clamped to
+     * {@link #MAX_LOG_LINES}.
+     */
+    String tailLog(String pid, int lines) {
+        if (!hasLog(pid)) {
+            return null;
+        }
+        int wanted = Math.max(1, Math.min(lines, MAX_LOG_LINES));
+        Path file = logFile(pid.trim());
+        try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
+            long length = raf.length();
+            if (length == 0) {
+                return "";
+            }
+            // read backwards in chunks until the buffer holds more line 
breaks than lines wanted (or the whole file)
+            int chunk = 64 * 1024;
+            byte[] tail = new byte[0];
+            long position = length;
+            while (position > 0 && countNewlines(tail) <= wanted) {
+                int size = (int) Math.min(chunk, position);
+                position -= size;
+                raf.seek(position);
+                byte[] merged = new byte[size + tail.length];
+                raf.readFully(merged, 0, size);
+                System.arraycopy(tail, 0, merged, size, tail.length);
+                tail = merged;
+            }
+            // skip the final line break, then step back over 'wanted' line 
breaks
+            int cut = tail.length;
+            if (cut > 0 && tail[cut - 1] == '\n') {
+                cut--;
+            }
+            int seen = 0;
+            for (int i = cut - 1; i >= 0; i--) {
+                if (tail[i] == '\n' && ++seen == wanted) {
+                    return new String(tail, i + 1, tail.length - i - 1, 
StandardCharsets.UTF_8);
+                }
+            }
+            return new String(tail, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static int countNewlines(byte[] bytes) {
+        int count = 0;
+        for (byte b : bytes) {
+            if (b == '\n') {
+                count++;
+            }
+        }
+        return count;
+    }
+
     /**
      * The parsed document, or {@code null} when there is no readable status 
file for the pid.
      */
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 25edf336c4c5..a49bb1aff14f 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
@@ -284,7 +284,9 @@ class TuiMcpServer {
     // ---- Resources: the per-process status document, whole or one section 
at a time ----
 
     static final String STATUS_URI_PREFIX = "camel://status/";
+    static final String LOG_URI_PREFIX = "camel://log/";
     private static final String JSON_MIME = "application/json";
+    private static final String TEXT_MIME = "text/plain";
 
     /**
      * Address of a status resource: {@code camel://status/<pid>} for the 
whole document, or
@@ -318,6 +320,49 @@ class TuiMcpServer {
         }
     }
 
+    /**
+     * Address of a process log resource: {@code camel://log/<pid>} for the 
last
+     * {@link StatusFileReader#DEFAULT_LOG_LINES} lines, or {@code 
camel://log/<pid>?lines=<n>} for a different tail
+     * length.
+     */
+    record LogUri(String pid, int lines) {
+
+        String uri() {
+            return LOG_URI_PREFIX + pid + (lines != 
StatusFileReader.DEFAULT_LOG_LINES ? "?lines=" + lines : "");
+        }
+
+        /**
+         * Parses a log resource URI, or returns {@code null} when it is not 
one.
+         */
+        static LogUri parse(String uri) {
+            if (uri == null || !uri.startsWith(LOG_URI_PREFIX)) {
+                return null;
+            }
+            String rest = uri.substring(LOG_URI_PREFIX.length());
+            int query = rest.indexOf('?');
+            String pid = query < 0 ? rest : rest.substring(0, query);
+            if (pid.isEmpty() || !pid.chars().allMatch(Character::isDigit)) {
+                return null;
+            }
+            int lines = StatusFileReader.DEFAULT_LOG_LINES;
+            if (query >= 0) {
+                String param = rest.substring(query + 1);
+                if (!param.startsWith("lines=")) {
+                    return null;
+                }
+                try {
+                    lines = 
Integer.parseInt(param.substring("lines=".length()));
+                } catch (NumberFormatException e) {
+                    return null;
+                }
+                if (lines < 1) {
+                    return null;
+                }
+            }
+            return new LogUri(pid, Math.min(lines, 
StatusFileReader.MAX_LOG_LINES));
+        }
+    }
+
     private JsonObject handleResourcesList() {
         JsonObject result = new JsonObject();
         result.put("resources", buildResourceList(facade.liveIntegrations(), 
facade.statusFiles()));
@@ -338,22 +383,32 @@ class TuiMcpServer {
                 continue;
             }
             String name = info.name != null ? info.name : info.pid;
+            if (reader.hasLog(info.pid)) {
+                resources.add(resource(new LogUri(info.pid, 
StatusFileReader.DEFAULT_LOG_LINES).uri(), name + " log",
+                        "Last " + StatusFileReader.DEFAULT_LOG_LINES + " log 
lines of " + name + " (PID " + info.pid
+                                                                               
                                       + "); append ?lines=<n> for a longer or 
shorter tail (max "
+                                                                               
                                       + StatusFileReader.MAX_LOG_LINES
+                                                                               
                                       + ")",
+                        TEXT_MIME));
+            }
             resources.add(resource(new StatusUri(info.pid, null).uri(), name + 
" status",
-                    "Full status document of " + name + " (PID " + info.pid + 
"): " + String.join(", ", sections)));
+                    "Full status document of " + name + " (PID " + info.pid + 
"): " + String.join(", ", sections),
+                    JSON_MIME));
             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 + ")"));
+                        "The '" + section + "' section of the status document 
of " + name + " (PID " + info.pid + ")",
+                        JSON_MIME));
             }
         }
         return resources;
     }
 
-    private static JsonObject resource(String uri, String name, String 
description) {
+    private static JsonObject resource(String uri, String name, String 
description, String mimeType) {
         JsonObject resource = new JsonObject();
         resource.put("uri", uri);
         resource.put("name", name);
         resource.put("description", description);
-        resource.put("mimeType", JSON_MIME);
+        resource.put("mimeType", mimeType);
         return resource;
     }
 
@@ -366,8 +421,17 @@ class TuiMcpServer {
                                     + "Sections include context, runtime, 
routes, endpoints, healthChecks, properties, "
                                     + "main-configuration, memory, threads, gc 
and events.");
         template.put("mimeType", JSON_MIME);
+        JsonObject logTemplate = new JsonObject();
+        logTemplate.put("uriTemplate", LOG_URI_PREFIX + "{pid}{?lines}");
+        logTemplate.put("name", "Integration log tail");
+        logTemplate.put("description", "The last lines of the log of the 
integration with the given PID "
+                                       + "(" + 
StatusFileReader.DEFAULT_LOG_LINES + " by default, lines=<n> up to "
+                                       + StatusFileReader.MAX_LOG_LINES + "). 
The Log tab shows only a window of this "
+                                       + "file; use the tui_get_screen tool to 
see which lines the user is looking at.");
+        logTemplate.put("mimeType", TEXT_MIME);
         JsonArray templates = new JsonArray();
         templates.add(template);
+        templates.add(logTemplate);
         JsonObject result = new JsonObject();
         result.put("resourceTemplates", templates);
         return result;
@@ -378,7 +442,13 @@ class TuiMcpServer {
      * (the caller turns that into a "resource not found" error).
      */
     private JsonObject handleResourcesRead(JsonObject request) {
-        StatusUri uri = StatusUri.parse(requestedUri(request));
+        String requested = requestedUri(request);
+        LogUri logUri = LogUri.parse(requested);
+        if (logUri != null) {
+            String tail = facade.statusFiles().tailLog(logUri.pid(), 
logUri.lines());
+            return tail == null ? null : contents(logUri.uri(), TEXT_MIME, 
tail);
+        }
+        StatusUri uri = StatusUri.parse(requested);
         if (uri == null) {
             return null;
         }
@@ -388,10 +458,14 @@ class TuiMcpServer {
         if (payload == null) {
             return null;
         }
+        return contents(uri.uri(), JSON_MIME, Jsoner.serialize(payload));
+    }
+
+    private static JsonObject contents(String uri, String mimeType, String 
text) {
         JsonObject content = new JsonObject();
-        content.put("uri", uri.uri());
-        content.put("mimeType", JSON_MIME);
-        content.put("text", Jsoner.serialize(payload));
+        content.put("uri", uri);
+        content.put("mimeType", mimeType);
+        content.put("text", text);
         JsonArray contents = new JsonArray();
         contents.add(content);
         JsonObject result = new JsonObject();
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
index c75cd7166739..302efd5efe26 100644
--- 
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
@@ -56,6 +56,26 @@ class StatusFileReaderTest {
         assertNull(reader.section("4711", null));
     }
 
+    @Test
+    void tailsTheLogFromTheEndWithoutLoadingItWhole(@TempDir Path dir) throws 
Exception {
+        StringBuilder log = new StringBuilder();
+        for (int i = 1; i <= 3000; i++) {
+            log.append("2026-09-08 line ").append(i).append(" 
").append("padding ".repeat(20)).append('\n');
+        }
+        Files.writeString(dir.resolve("4711.log"), log.toString());
+        StatusFileReader reader = new StatusFileReader(dir);
+
+        assertTrue(reader.hasLog("4711"));
+        String tail = reader.tailLog("4711", 3);
+        assertTrue(tail.startsWith("2026-09-08 line 2998 "), tail.substring(0, 
40));
+        assertEquals(3, tail.strip().split("\n").length);
+        assertTrue(tail.strip().endsWith("line 3000 " + "padding 
".repeat(20).strip()));
+
+        // more lines than the file has yields the whole file
+        assertEquals(log.toString(), reader.tailLog("4711", 
StatusFileReader.MAX_LOG_LINES));
+        assertNull(reader.tailLog("9999", 10));
+    }
+
     @Test
     void missingOrCorruptFilesReadAsAbsent(@TempDir Path dir) throws Exception 
{
         Files.writeString(dir.resolve("99-status.json"), "{ not json");
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
index 9d6bf34720e8..aab18ff1be51 100644
--- 
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
@@ -55,9 +55,29 @@ class TuiMcpServerResourcesTest {
     }
 
     @Test
-    void listsWholeDocumentThenEachSectionPerIntegration(@TempDir Path dir) 
throws Exception {
+    void parsesLogUrisWithOptionalLineCount() {
+        TuiMcpServer.LogUri plain = 
TuiMcpServer.LogUri.parse("camel://log/4711");
+        assertEquals("4711", plain.pid());
+        assertEquals(StatusFileReader.DEFAULT_LOG_LINES, plain.lines());
+        assertEquals("camel://log/4711", plain.uri());
+
+        TuiMcpServer.LogUri sized = 
TuiMcpServer.LogUri.parse("camel://log/4711?lines=50");
+        assertEquals(50, sized.lines());
+        assertEquals("camel://log/4711?lines=50", sized.uri());
+        assertEquals(StatusFileReader.MAX_LOG_LINES, 
TuiMcpServer.LogUri.parse("camel://log/4711?lines=999999").lines());
+
+        assertNull(TuiMcpServer.LogUri.parse("camel://log/"));
+        assertNull(TuiMcpServer.LogUri.parse("camel://log/abc"));
+        assertNull(TuiMcpServer.LogUri.parse("camel://log/4711?lines=0"));
+        assertNull(TuiMcpServer.LogUri.parse("camel://log/4711?tail=5"));
+        assertNull(TuiMcpServer.LogUri.parse("camel://status/4711"));
+    }
+
+    @Test
+    void listsLogThenWholeDocumentThenEachSectionPerIntegration(@TempDir Path 
dir) throws Exception {
         Files.writeString(dir.resolve("4711-status.json"),
                 
"{\"runtime\":{\"pid\":4711},\"context\":{\"name\":\"timer-log\"}}");
+        Files.writeString(dir.resolve("4711.log"), "hello\n");
         IntegrationInfo running = new IntegrationInfo();
         running.pid = "4711";
         running.name = "timer-log";
@@ -67,12 +87,16 @@ class TuiMcpServerResourcesTest {
 
         JsonArray resources = TuiMcpServer.buildResourceList(List.of(running, 
gone), new StatusFileReader(dir));
 
-        assertEquals(3, resources.size());
-        JsonObject whole = (JsonObject) resources.get(0);
+        assertEquals(4, resources.size());
+        JsonObject log = (JsonObject) resources.get(0);
+        assertEquals("camel://log/4711", log.get("uri"));
+        assertEquals("timer-log log", log.get("name"));
+        assertEquals("text/plain", log.get("mimeType"));
+        JsonObject whole = (JsonObject) resources.get(1);
         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"));
+        assertEquals("camel://status/4711/runtime", ((JsonObject) 
resources.get(2)).get("uri"));
+        assertEquals("camel://status/4711/context", ((JsonObject) 
resources.get(3)).get("uri"));
     }
 }

Reply via email to