This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 245115570f7e27d0382b13e57c1325f4c79b2634
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun May 24 18:38:12 2026 +0200

    CAMEL-23606: camel-tui - embedded MCP server for AI agent observability
    
    Adds an embedded MCP (Model Context Protocol) server to the Camel TUI,
    allowing AI coding agents to observe the live terminal session. Uses JDK's
    built-in HttpServer with the Streamable HTTP transport (spec 2025-03-26),
    requiring zero new dependencies.
    
    Phase 1 provides three observation tools:
    - tui_get_screen: returns current screen content (plain text or ANSI)
    - tui_get_events: returns recent key presses and navigation events
    - tui_get_state: returns active tab, selected integration, etc.
    
    Features:
    - --mcp flag to enable, --mcp-port to configure (default 8123)
    - Auto-generates .mcp.json for zero-config Claude Code discovery
    - Footer indicator with connected client name and activity dot
    - F2 menu: MCP Info (setup guide) and MCP Log (colored activity log)
    - Graceful handling when port is already in use
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../dsl/jbang/core/commands/tui/ActionsPopup.java  | 205 +++++++++--
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  | 123 +++++++
 .../dsl/jbang/core/commands/tui/TuiEventLog.java   |  57 ++++
 .../dsl/jbang/core/commands/tui/TuiMcpServer.java  | 374 +++++++++++++++++++++
 4 files changed, 728 insertions(+), 31 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index 2db68d8234be..46022d003015 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -44,6 +44,7 @@ import dev.tamboui.widgets.list.ListItem;
 import dev.tamboui.widgets.list.ListState;
 import dev.tamboui.widgets.list.ListWidget;
 import dev.tamboui.widgets.list.ScrollMode;
+import dev.tamboui.widgets.paragraph.Paragraph;
 import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
 import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
 import org.apache.camel.dsl.jbang.core.common.PathUtils;
@@ -61,8 +62,9 @@ class ActionsPopup {
     private static final int ACTION_SHOW_KEYSTROKES = 4;
     private static final int ACTION_DOCTOR = 5;
     private static final int ACTION_CLASSPATH = 6;
-    private static final int ACTION_STOP_ALL = 7;
-    private static final int ACTION_COUNT = 8;
+    private static final int ACTION_MCP_INFO = 7;
+    private static final int ACTION_MCP_LOG = 8;
+    private static final int ACTION_STOP_ALL = 9;
 
     private final Supplier<Set<String>> runningNames;
     private final Supplier<List<IntegrationInfo>> integrations;
@@ -70,6 +72,10 @@ class ActionsPopup {
     private final Runnable toggleKeystrokes;
     private final Supplier<Boolean> keystrokesEnabled;
     private MonitorContext ctx;
+    private boolean mcpEnabled;
+    private int mcpPort;
+    private Supplier<String> mcpConnectedClient;
+    private Supplier<List<TuiMcpServer.LogEntry>> mcpActivityLog;
 
     private boolean showActionsMenu;
     private final ListState actionsMenuState = new ListState();
@@ -87,6 +93,7 @@ class ActionsPopup {
     private boolean showDocViewer;
     private boolean docViewerFromExampleBrowser;
     private String docContent;
+    private List<Line> docLines;
     private String docTitle;
     private int docScroll;
 
@@ -116,6 +123,18 @@ class ActionsPopup {
         this.ctx = ctx;
     }
 
+    void setMcpEnabled(
+            boolean enabled, int port, Supplier<String> connectedClient, 
Supplier<List<TuiMcpServer.LogEntry>> activityLog) {
+        this.mcpEnabled = enabled;
+        this.mcpPort = port;
+        this.mcpConnectedClient = connectedClient;
+        this.mcpActivityLog = activityLog;
+    }
+
+    private int actionCount() {
+        return mcpEnabled ? 10 : 8;
+    }
+
     boolean isVisible() {
         return showActionsMenu || showExampleBrowser || 
runOptionsForm.isVisible() || showDocPicker || showDocViewer
                 || doctorPopup.isVisible() || classpathPopup.isVisible()
@@ -230,31 +249,38 @@ class ActionsPopup {
             } else if (ke.isUp()) {
                 actionsMenuState.selectPrevious();
             } else if (ke.isDown()) {
-                actionsMenuState.selectNext(ACTION_COUNT);
+                actionsMenuState.selectNext(actionCount());
             } else if (ke.isConfirm()) {
                 Integer sel = actionsMenuState.selected();
                 if (sel != null) {
-                    if (sel == ACTION_RUN_EXAMPLE) {
+                    int action = resolveAction(sel);
+                    if (action == ACTION_RUN_EXAMPLE) {
                         openExampleBrowser();
-                    } else if (sel == ACTION_SHOW_DOCS) {
+                    } else if (action == ACTION_SHOW_DOCS) {
                         openDocPicker();
-                    } else if (sel == ACTION_SCREENSHOT) {
+                    } else if (action == ACTION_SCREENSHOT) {
                         showActionsMenu = false;
                         screenshotAction.run();
-                    } else if (sel == ACTION_SHOW_KEYSTROKES) {
+                    } else if (action == ACTION_SHOW_KEYSTROKES) {
                         showActionsMenu = false;
                         toggleKeystrokes.run();
-                    } else if (sel == ACTION_DOCTOR) {
+                    } else if (action == ACTION_DOCTOR) {
                         showActionsMenu = false;
                         doctorPopup.open();
-                    } else if (sel == ACTION_CLASSPATH) {
+                    } else if (action == ACTION_CLASSPATH) {
                         showActionsMenu = false;
                         openClasspath();
-                    } else if (sel == ACTION_STOP_ALL) {
+                    } else if (action == ACTION_MCP_INFO) {
+                        showActionsMenu = false;
+                        openMcpInfo();
+                    } else if (action == ACTION_MCP_LOG) {
+                        showActionsMenu = false;
+                        openMcpLog();
+                    } else if (action == ACTION_STOP_ALL) {
                         showActionsMenu = false;
                         stopAllPopup.open();
                         checkStopAllNotification();
-                    } else if (sel == ACTION_CAPTION) {
+                    } else if (action == ACTION_CAPTION) {
                         showActionsMenu = false;
                         captionOverlay.openInput();
                     }
@@ -352,8 +378,9 @@ class ActionsPopup {
     // ---- Rendering ----
 
     private void renderActionsMenu(Frame frame, Rect area) {
+        int count = actionCount();
         int popupW = 34;
-        int popupH = 2 + ACTION_COUNT;
+        int popupH = 2 + count;
         int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
         int y = area.top() + Math.max(0, (area.height() - popupH) / 2);
         Rect popup = new Rect(x, y, Math.min(popupW, area.width()), 
Math.min(popupH, area.height()));
@@ -366,15 +393,21 @@ class ActionsPopup {
         String stopLabel = stopAllPopup.hasBothGroups()
                 ? "  🛑 Stop All..."
                 : "  🛑 Stop All";
+        List<ListItem> items = new ArrayList<>();
+        items.add(ListItem.from("  🐪 Run an example..."));
+        items.add(ListItem.from("  📖 Show Documentation"));
+        items.add(ListItem.from("  💬 Caption... (Ctrl+T)"));
+        items.add(ListItem.from("  📸 Take Screenshot"));
+        items.add(ListItem.from(keystrokeLabel));
+        items.add(ListItem.from("  🩺 Run Doctor"));
+        items.add(ListItem.from("  📦 Show Classpath"));
+        if (mcpEnabled) {
+            items.add(ListItem.from("  🤖 MCP Info"));
+            items.add(ListItem.from("  📋 MCP Log"));
+        }
+        items.add(ListItem.from(stopLabel));
         ListWidget list = ListWidget.builder()
-                .items(ListItem.from("  🐪 Run an example..."),
-                        ListItem.from("  📖 Show Documentation"),
-                        ListItem.from("  💬 Caption... (Ctrl+T)"),
-                        ListItem.from("  📸 Take Screenshot"),
-                        ListItem.from(keystrokeLabel),
-                        ListItem.from("  🩺 Run Doctor"),
-                        ListItem.from("  📦 Show Classpath"),
-                        ListItem.from(stopLabel))
+                .items(items.toArray(ListItem[]::new))
                 .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
                 .highlightSymbol("")
                 .scrollMode(ScrollMode.NONE)
@@ -465,18 +498,32 @@ class ActionsPopup {
     private void renderDocViewer(Frame frame, Rect area) {
         Rect popup = new Rect(area.left() + 2, area.top() + 1, area.width() - 
4, area.height() - 2);
         frame.renderWidget(Clear.INSTANCE, popup);
-        MarkdownView view = MarkdownView.builder()
-                .source(docContent)
-                .scroll(docScroll)
-                .block(Block.builder()
-                        .borderType(BorderType.ROUNDED)
-                        .title(" " + docTitle + " ")
-                        .titleBottom(Title.from(Line.from(
-                                Span.styled(" ↑↓", 
MonitorContext.HINT_KEY_STYLE), Span.raw(" scroll │"),
-                                Span.styled(" Esc", 
MonitorContext.HINT_KEY_STYLE), Span.raw(" back "))))
-                        .build())
+        Block block = Block.builder()
+                .borderType(BorderType.ROUNDED)
+                .title(" " + docTitle + " ")
+                .titleBottom(Title.from(Line.from(
+                        Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), 
Span.raw(" scroll │"),
+                        Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), 
Span.raw(" back "))))
                 .build();
-        frame.renderWidget(view, popup);
+        if (docLines != null) {
+            frame.renderWidget(block, popup);
+            Rect inner = block.inner(popup);
+            int visibleLines = inner.height();
+            int totalLines = docLines.size();
+            int clampedScroll = Math.min(docScroll, Math.max(0, totalLines - 
visibleLines));
+            int end = Math.min(clampedScroll + visibleLines, totalLines);
+            List<Line> visible = docLines.subList(clampedScroll, end);
+            frame.renderWidget(
+                    
Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
+                    inner);
+        } else {
+            MarkdownView view = MarkdownView.builder()
+                    .source(docContent)
+                    .scroll(docScroll)
+                    .block(block)
+                    .build();
+            frame.renderWidget(view, popup);
+        }
     }
 
     private void renderDocPicker(Frame frame, Rect area) {
@@ -544,6 +591,7 @@ class ActionsPopup {
         if (ctx == null) {
             return;
         }
+        docLines = null;
         showDocPicker = false;
         try {
             Path outputFile = ctx.getOutputFile(info.pid);
@@ -601,6 +649,7 @@ class ActionsPopup {
         }
         if (content != null && !content.isEmpty()) {
             docContent = isAdoc ? DocHelper.asciidocToMarkdown(content) : 
content;
+            docLines = null;
             docTitle = name;
             docScroll = 0;
             showExampleBrowser = false;
@@ -624,6 +673,100 @@ class ActionsPopup {
         }
     }
 
+    private int resolveAction(int index) {
+        if (!mcpEnabled && index >= ACTION_MCP_INFO) {
+            return index + 2;
+        }
+        return index;
+    }
+
+    private void openMcpInfo() {
+        docLines = null;
+        String url = "http://localhost:"; + mcpPort + "/mcp";
+        String client = mcpConnectedClient != null ? mcpConnectedClient.get() 
: null;
+        String status = client != null
+                ? "**Connected:** " + client
+                : "**Status:** Not connected";
+        docContent = "# MCP Server\n\n"
+                     + status + "\n\n"
+                     + "The TUI has an embedded MCP (Model Context Protocol) 
server running at:\n\n"
+                     + "    " + url + "\n\n"
+                     + "This allows AI coding agents to observe your TUI 
session — see the screen,\n"
+                     + "follow your key presses, and understand what you're 
doing.\n\n"
+                     + "## Available Tools\n\n"
+                     + "| Tool | Description |\n"
+                     + "|------|-------------|\n"
+                     + "| `tui_get_screen` | Returns the current screen 
content as text |\n"
+                     + "| `tui_get_events` | Returns recent key presses and 
navigation events |\n"
+                     + "| `tui_get_state` | Returns active tab, selected 
integration, etc. |\n\n"
+                     + "## Setup for Claude Code\n\n"
+                     + "Run this command to connect Claude Code to the 
TUI:\n\n"
+                     + "    claude mcp add --transport http camel-tui " + url 
+ "\n\n"
+                     + "Or add to your project's `.mcp.json`:\n\n"
+                     + "    {\n"
+                     + "      \"mcpServers\": {\n"
+                     + "        \"camel-tui\": {\n"
+                     + "          \"type\": \"http\",\n"
+                     + "          \"url\": \"" + url + "\"\n"
+                     + "        }\n"
+                     + "      }\n"
+                     + "    }\n\n"
+                     + "A `.mcp.json` file is auto-generated in the current 
directory while the TUI\n"
+                     + "is running with `--mcp` enabled. It is removed when 
the TUI exits.\n\n"
+                     + "## Usage Examples\n\n"
+                     + "Once connected, ask your AI agent:\n\n"
+                     + "- \"What's on my Camel TUI screen right now?\"\n"
+                     + "- \"What tab am I on and what integration is 
selected?\"\n"
+                     + "- \"What keys did I press in the last minute?\"\n"
+                     + "- \"What color is the throughput chart?\"\n";
+        docTitle = "MCP Info";
+        docScroll = 0;
+        showDocViewer = true;
+        docViewerFromExampleBrowser = false;
+    }
+
+    private void openMcpLog() {
+        List<TuiMcpServer.LogEntry> entries = mcpActivityLog != null ? 
mcpActivityLog.get() : List.of();
+        if (entries.isEmpty()) {
+            docContent = "No MCP activity yet.";
+            docLines = null;
+        } else {
+            docContent = null;
+            List<Line> lines = new ArrayList<>();
+            for (TuiMcpServer.LogEntry entry : entries) {
+                String levelTag;
+                Style levelStyle;
+                switch (entry.level()) {
+                    case CONNECT:
+                        levelTag = " CONNECT ";
+                        levelStyle = Style.EMPTY.fg(Color.GREEN);
+                        break;
+                    case TOOL:
+                        levelTag = " TOOL    ";
+                        levelStyle = Style.EMPTY.fg(Color.CYAN);
+                        break;
+                    case ERROR:
+                        levelTag = " ERROR   ";
+                        levelStyle = Style.EMPTY.fg(Color.LIGHT_RED);
+                        break;
+                    default:
+                        levelTag = " INFO    ";
+                        levelStyle = Style.EMPTY.fg(Color.GREEN);
+                        break;
+                }
+                lines.add(Line.from(
+                        Span.styled(entry.timestamp(), Style.EMPTY.dim()),
+                        Span.styled(levelTag, levelStyle),
+                        Span.raw(entry.message())));
+            }
+            docLines = lines;
+        }
+        docTitle = "MCP Log";
+        docScroll = 0;
+        showDocViewer = true;
+        docViewerFromExampleBrowser = false;
+    }
+
     private void openClasspath() {
         if (ctx == null) {
             return;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 72a1265c6d39..7018f442d50d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -130,6 +130,15 @@ public class CamelMonitor extends CamelCommand {
                         arity = "0..1")
     String record;
 
+    @CommandLine.Option(names = { "--mcp" },
+                        description = "Enable embedded MCP server for AI agent 
access to the TUI")
+    boolean mcp;
+
+    @CommandLine.Option(names = { "--mcp-port" },
+                        description = "MCP server port (default: 
${DEFAULT-VALUE})",
+                        defaultValue = "8123")
+    int mcpPort = 8123;
+
     // State
     private final AtomicReference<List<IntegrationInfo>> data = new 
AtomicReference<>(Collections.emptyList());
     private final AtomicReference<List<InfraInfo>> infraData = new 
AtomicReference<>(Collections.emptyList());
@@ -201,6 +210,8 @@ public class CamelMonitor extends CamelCommand {
     private volatile long screenshotMessageTime;
     private volatile boolean pendingScreenshot;
     private boolean recording;
+    private TuiEventLog eventLog;
+    private TuiMcpServer mcpServer;
     private final List<KeyRecord> recentKeys = new ArrayList<>();
     private final CaptionOverlay captionOverlay = new CaptionOverlay();
 
@@ -281,6 +292,22 @@ public class CamelMonitor extends CamelCommand {
         // Initial data load (synchronous before TUI starts)
         refreshDataSync();
 
+        eventLog = new TuiEventLog(500);
+        Path mcpJsonFile = null;
+        if (mcp) {
+            mcpServer = new TuiMcpServer(mcpPort, this);
+            try {
+                mcpServer.start();
+                actionsPopup.setMcpEnabled(true, mcpPort, 
mcpServer::getConnectedClient, mcpServer::getActivityLog);
+                mcpJsonFile = writeMcpJson(mcpPort);
+            } catch (java.net.BindException e) {
+                System.err.println("MCP server failed to start: port " + 
mcpPort + " is already in use.");
+                System.err.println("Use --mcp-port to specify a different 
port, e.g.: camel tui --mcp --mcp-port 8124");
+                mcpServer = null;
+                mcp = false;
+            }
+        }
+
         try (var tui = TuiRunner.create()) {
             this.runner = tui;
             ctx.runner = tui;
@@ -291,6 +318,10 @@ public class CamelMonitor extends CamelCommand {
                     this::handleEvent,
                     this::render);
         } finally {
+            if (mcpServer != null) {
+                mcpServer.stop();
+            }
+            deleteMcpJson(mcpJsonFile);
             this.runner = null;
         }
         return 0;
@@ -300,6 +331,12 @@ public class CamelMonitor extends CamelCommand {
 
     private boolean handleEvent(Event event, TuiRunner runner) {
         if (event instanceof KeyEvent ke) {
+            if (eventLog != null) {
+                String elabel = keyLabel(ke);
+                if (elabel != null) {
+                    eventLog.record(elabel, elabel);
+                }
+            }
             if (recording) {
                 String label = keyLabel(ke);
                 if (label != null) {
@@ -1623,6 +1660,21 @@ public class CamelMonitor extends CamelCommand {
             spans.addAll(keySpans);
         }
 
+        if (mcp && !recording) {
+            String client = mcpServer != null ? mcpServer.getConnectedClient() 
: null;
+            boolean active = mcpServer != null && mcpServer.isRecentActivity();
+            String dot = active ? " ●" : " ○";
+            String mcpLabel = client != null
+                    ? "MCP :" + mcpPort + " (" + client + ")"
+                    : "MCP :" + mcpPort;
+            int hintsWidth = spans.stream().mapToInt(s -> s.width()).sum();
+            int mcpWidth = mcpLabel.length() + dot.length() + 1;
+            int gap = Math.max(1, area.width() - hintsWidth - mcpWidth);
+            spans.add(Span.raw(" ".repeat(gap)));
+            spans.add(Span.styled(mcpLabel, Style.EMPTY.fg(client != null ? 
Color.GREEN : Color.CYAN)));
+            spans.add(Span.styled(dot, Style.EMPTY.fg(active ? Color.GREEN : 
Color.DARK_GRAY)));
+        }
+
         frame.renderWidget(Paragraph.from(Line.from(spans)), area);
     }
 
@@ -2929,4 +2981,75 @@ public class CamelMonitor extends CamelCommand {
     record VanishingInfraInfo(InfraInfo info, long startTime) {
     }
 
+    // ---- MCP .mcp.json lifecycle ----
+
+    private static Path writeMcpJson(int port) {
+        Path path = Path.of(".mcp.json");
+        try {
+            String json = "{\n"
+                          + "  \"mcpServers\": {\n"
+                          + "    \"camel-tui\": {\n"
+                          + "      \"type\": \"http\",\n"
+                          + "      \"url\": \"http://localhost:"; + port + 
"/mcp\"\n"
+                          + "    }\n"
+                          + "  }\n"
+                          + "}\n";
+            Files.writeString(path, json);
+            return path;
+        } catch (IOException e) {
+            return null;
+        }
+    }
+
+    private static void deleteMcpJson(Path path) {
+        if (path != null) {
+            try {
+                Files.deleteIfExists(path);
+            } catch (IOException e) {
+                // best effort
+            }
+        }
+    }
+
+    // ---- MCP accessor methods ----
+
+    private static final String[] TAB_NAMES = {
+            "Overview", "Log", "Routes", "Consumers", "Endpoints",
+            "HTTP", "Health", "Inspect", "Circuit Breaker"
+    };
+
+    Buffer getLastBuffer() {
+        return lastBuffer;
+    }
+
+    TuiEventLog getEventLog() {
+        return eventLog;
+    }
+
+    int getActiveTabIndex() {
+        return tabsState.selected();
+    }
+
+    String getActiveTabName() {
+        int idx = tabsState.selected();
+        return idx >= 0 && idx < TAB_NAMES.length ? TAB_NAMES[idx] : "Unknown";
+    }
+
+    String getSelectedPid() {
+        return ctx != null ? ctx.selectedPid : null;
+    }
+
+    String getSelectedIntegrationName() {
+        if (ctx == null) {
+            return null;
+        }
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        return info != null ? info.name : null;
+    }
+
+    int getIntegrationCount() {
+        List<IntegrationInfo> list = data.get();
+        return (int) list.stream().filter(i -> !i.vanishing).count();
+    }
+
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiEventLog.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiEventLog.java
new file mode 100644
index 000000000000..52df4fe7e0f2
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiEventLog.java
@@ -0,0 +1,57 @@
+/*
+ * 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.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe bounded ring buffer for TUI key events. Used by the embedded 
MCP server to expose recent user
+ * interactions to AI agents.
+ */
+class TuiEventLog {
+
+    record Event(String key, String label, Instant timestamp) {
+    }
+
+    private final Event[] buffer;
+    private int head;
+    private int size;
+
+    TuiEventLog(int capacity) {
+        this.buffer = new Event[capacity];
+    }
+
+    synchronized void record(String key, String label) {
+        buffer[head] = new Event(key, label, Instant.now());
+        head = (head + 1) % buffer.length;
+        if (size < buffer.length) {
+            size++;
+        }
+    }
+
+    synchronized List<Event> getRecent(int limit) {
+        int count = Math.min(limit, size);
+        List<Event> result = new ArrayList<>(count);
+        int start = (head - count + buffer.length) % buffer.length;
+        for (int i = 0; i < count; i++) {
+            result.add(buffer[(start + i) % buffer.length]);
+        }
+        return result;
+    }
+}
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
new file mode 100644
index 000000000000..6b694e036b03
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -0,0 +1,374 @@
+/*
+ * 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.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.export.ExportRequest;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Embedded MCP (Model Context Protocol) server for the Camel TUI.
+ * <p>
+ * Implements the Streamable HTTP transport (spec 2025-03-26) using JDK's 
built-in HttpServer. Exposes tools that let AI
+ * agents observe the live TUI session: screen content, key events, and 
navigation state.
+ * <p>
+ * Binds to 127.0.0.1 only for security.
+ */
+class TuiMcpServer {
+
+    private static final String SERVER_NAME = "camel-tui-mcp";
+    private static final String SERVER_VERSION = "1.0.0";
+    private static final String PROTOCOL_VERSION = "2025-03-26";
+    private static final long CLIENT_TIMEOUT_MS = 60_000;
+
+    private static final int MAX_LOG_ENTRIES = 200;
+    private static final DateTimeFormatter TIME_FMT = 
DateTimeFormatter.ofPattern("HH:mm:ss")
+            .withZone(ZoneId.systemDefault());
+
+    enum LogLevel {
+        INFO,
+        CONNECT,
+        TOOL,
+        ERROR
+    }
+
+    record LogEntry(String timestamp, LogLevel level, String message) {
+    }
+
+    private final int port;
+    private final CamelMonitor monitor;
+    private HttpServer server;
+    private volatile String clientName;
+    private volatile long lastActivity;
+    private volatile long lastToolCallTime;
+    private final List<LogEntry> activityLog = new ArrayList<>();
+
+    TuiMcpServer(int port, CamelMonitor monitor) {
+        this.port = port;
+        this.monitor = monitor;
+    }
+
+    void start() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 
0);
+        server.createContext("/mcp", this::handleMcp);
+        server.start();
+        log(LogLevel.INFO, "Server started on port " + port);
+    }
+
+    void stop() {
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    synchronized List<LogEntry> getActivityLog() {
+        return new ArrayList<>(activityLog);
+    }
+
+    private synchronized void log(LogLevel level, String message) {
+        activityLog.add(new LogEntry(TIME_FMT.format(Instant.now()), level, 
message));
+        if (activityLog.size() > MAX_LOG_ENTRIES) {
+            activityLog.remove(0);
+        }
+    }
+
+    boolean isRecentActivity() {
+        return System.currentTimeMillis() - lastToolCallTime < 2000;
+    }
+
+    String getConnectedClient() {
+        if (clientName != null && System.currentTimeMillis() - lastActivity < 
CLIENT_TIMEOUT_MS) {
+            return clientName;
+        }
+        return null;
+    }
+
+    private void handleMcp(HttpExchange exchange) throws IOException {
+        try {
+            lastActivity = System.currentTimeMillis();
+            String method = exchange.getRequestMethod();
+            if (!"POST".equals(method)) {
+                exchange.sendResponseHeaders(405, -1);
+                return;
+            }
+
+            String body;
+            try (InputStream is = exchange.getRequestBody()) {
+                body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+            }
+
+            JsonObject request = Jsoner.deserialize(body, new JsonObject());
+            String jsonrpcMethod = (String) request.get("method");
+
+            if (jsonrpcMethod == null) {
+                sendError(exchange, request, -32600, "Invalid Request");
+                return;
+            }
+
+            // Notifications have no "id" — respond with 202
+            if (!request.containsKey("id")) {
+                exchange.sendResponseHeaders(202, -1);
+                return;
+            }
+
+            JsonObject result = switch (jsonrpcMethod) {
+                case "initialize" -> handleInitialize(request);
+                case "tools/list" -> handleToolsList();
+                case "tools/call" -> handleToolsCall(request);
+                case "ping" -> new JsonObject();
+                default -> null;
+            };
+
+            if (result == null) {
+                sendError(exchange, request, -32601, "Method not found: " + 
jsonrpcMethod);
+            } else {
+                sendResult(exchange, request, result);
+            }
+        } catch (Exception e) {
+            exchange.sendResponseHeaders(500, -1);
+        } finally {
+            exchange.close();
+        }
+    }
+
+    private JsonObject handleInitialize(JsonObject request) {
+        JsonObject params = (JsonObject) request.get("params");
+        if (params != null) {
+            JsonObject clientInfo = (JsonObject) params.get("clientInfo");
+            if (clientInfo != null) {
+                clientName = (String) clientInfo.get("name");
+            }
+        }
+        log(LogLevel.CONNECT, "Client connected: " + (clientName != null ? 
clientName : "unknown"));
+
+        JsonObject result = new JsonObject();
+        result.put("protocolVersion", PROTOCOL_VERSION);
+
+        JsonObject serverInfo = new JsonObject();
+        serverInfo.put("name", SERVER_NAME);
+        serverInfo.put("version", SERVER_VERSION);
+        result.put("serverInfo", serverInfo);
+
+        JsonObject capabilities = new JsonObject();
+        JsonObject tools = new JsonObject();
+        tools.put("listChanged", false);
+        capabilities.put("tools", tools);
+        result.put("capabilities", capabilities);
+
+        return result;
+    }
+
+    private JsonObject handleToolsList() {
+        JsonArray toolList = new JsonArray();
+        toolList.add(toolDef(
+                "tui_get_screen",
+                "Returns the current TUI screen content as text. "
+                                  + "Shows exactly what the user sees in their 
terminal. "
+                                  + "Use ansi=true to include ANSI color codes 
for color-related questions.",
+                Map.of("ansi", propDef("boolean", "Include ANSI color codes in 
the output (default false)"))));
+        toolList.add(toolDef(
+                "tui_get_events",
+                "Returns recent user input events (key presses, navigation). "
+                                  + "Each event has a key, human-readable 
label, and timestamp.",
+                Map.of("limit", propDef("integer", "Maximum number of events 
to return (default 50)"))));
+        toolList.add(toolDef(
+                "tui_get_state",
+                "Returns the current TUI navigation state: active tab, 
selected integration, "
+                                 + "and integration count.",
+                Map.of()));
+
+        JsonObject result = new JsonObject();
+        result.put("tools", toolList);
+        return result;
+    }
+
+    @SuppressWarnings("unchecked")
+    private JsonObject handleToolsCall(JsonObject request) {
+        JsonObject params = (JsonObject) request.get("params");
+        String toolName = params != null ? (String) params.get("name") : null;
+        Map<String, Object> args = params != null ? (Map<String, Object>) 
params.get("arguments") : Map.of();
+        if (args == null) {
+            args = Map.of();
+        }
+
+        lastToolCallTime = System.currentTimeMillis();
+        log(LogLevel.TOOL, "Tool call: " + toolName);
+
+        String text;
+        boolean isError = false;
+        try {
+            text = switch (toolName) {
+                case "tui_get_screen" -> callGetScreen(args);
+                case "tui_get_events" -> callGetEvents(args);
+                case "tui_get_state" -> callGetState();
+                default -> {
+                    isError = true;
+                    yield "Unknown tool: " + toolName;
+                }
+            };
+        } catch (Exception e) {
+            text = "Error: " + e.getMessage();
+            isError = true;
+            log(LogLevel.ERROR, "Tool error: " + toolName + " - " + 
e.getMessage());
+        }
+
+        JsonObject content = new JsonObject();
+        content.put("type", "text");
+        content.put("text", text);
+
+        JsonArray contentArray = new JsonArray();
+        contentArray.add(content);
+
+        JsonObject result = new JsonObject();
+        result.put("content", contentArray);
+        if (isError) {
+            result.put("isError", true);
+        }
+        return result;
+    }
+
+    private String callGetScreen(Map<String, Object> args) {
+        Buffer buf = monitor.getLastBuffer();
+        if (buf == null) {
+            return "Screen not yet available";
+        }
+        boolean ansi = Boolean.TRUE.equals(args.get("ansi"));
+        String screen = ansi
+                ? ExportRequest.export(buf).text().options(o -> 
o.styles(true)).toString()
+                : ExportRequest.export(buf).text().toString();
+
+        JsonObject result = new JsonObject();
+        result.put("screen", screen);
+        result.put("width", buf.area().width());
+        result.put("height", buf.area().height());
+        return Jsoner.serialize(result);
+    }
+
+    private String callGetEvents(Map<String, Object> args) {
+        int limit = 50;
+        Object limitArg = args.get("limit");
+        if (limitArg instanceof Number n) {
+            limit = n.intValue();
+        }
+
+        TuiEventLog eventLog = monitor.getEventLog();
+        List<TuiEventLog.Event> events = eventLog.getRecent(limit);
+
+        JsonArray eventsArray = new JsonArray();
+        for (TuiEventLog.Event event : events) {
+            JsonObject obj = new JsonObject();
+            obj.put("key", event.key());
+            obj.put("label", event.label());
+            obj.put("timestamp", event.timestamp().toString());
+            eventsArray.add(obj);
+        }
+
+        JsonObject result = new JsonObject();
+        result.put("events", eventsArray);
+        result.put("count", events.size());
+        return Jsoner.serialize(result);
+    }
+
+    private String callGetState() {
+        JsonObject result = new JsonObject();
+        result.put("activeTab", monitor.getActiveTabName());
+        result.put("tabIndex", monitor.getActiveTabIndex());
+
+        String pid = monitor.getSelectedPid();
+        if (pid != null) {
+            result.put("selectedPid", pid);
+        }
+        String name = monitor.getSelectedIntegrationName();
+        if (name != null) {
+            result.put("selectedIntegration", name);
+        }
+        result.put("integrationCount", monitor.getIntegrationCount());
+        return Jsoner.serialize(result);
+    }
+
+    // --- JSON-RPC helpers ---
+
+    private void sendResult(HttpExchange exchange, JsonObject request, 
JsonObject result) throws IOException {
+        JsonObject response = new JsonObject();
+        response.put("jsonrpc", "2.0");
+        response.put("id", request.get("id"));
+        response.put("result", result);
+        sendJson(exchange, 200, response);
+    }
+
+    private void sendError(HttpExchange exchange, JsonObject request, int 
code, String message) throws IOException {
+        JsonObject error = new JsonObject();
+        error.put("code", code);
+        error.put("message", message);
+
+        JsonObject response = new JsonObject();
+        response.put("jsonrpc", "2.0");
+        response.put("id", request.get("id"));
+        response.put("error", error);
+        sendJson(exchange, 200, response);
+    }
+
+    private void sendJson(HttpExchange exchange, int status, JsonObject json) 
throws IOException {
+        byte[] bytes = Jsoner.serialize(json).getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().set("Content-Type", "application/json");
+        exchange.sendResponseHeaders(status, bytes.length);
+        try (OutputStream os = exchange.getResponseBody()) {
+            os.write(bytes);
+        }
+    }
+
+    // --- Tool definition helpers ---
+
+    private JsonObject toolDef(String name, String description, Map<String, 
JsonObject> properties) {
+        JsonObject schema = new JsonObject();
+        schema.put("type", "object");
+        if (!properties.isEmpty()) {
+            JsonObject props = new JsonObject();
+            props.putAll(properties);
+            schema.put("properties", props);
+        }
+
+        JsonObject tool = new JsonObject();
+        tool.put("name", name);
+        tool.put("description", description);
+        tool.put("inputSchema", schema);
+        return tool;
+    }
+
+    private JsonObject propDef(String type, String description) {
+        JsonObject prop = new JsonObject();
+        prop.put("type", type);
+        prop.put("description", description);
+        return prop;
+    }
+}


Reply via email to