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 d3bcfdab6af53a4df8a1d6dd545211c458c5d209 Author: Claus Ibsen <[email protected]> AuthorDate: Sun May 24 22:52:59 2026 +0200 CAMEL-23606: camel-tui - MCP log master/detail view with request/response capture Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../dsl/jbang/core/commands/tui/ActionsPopup.java | 182 ++++++++++++++++----- .../dsl/jbang/core/commands/tui/TuiMcpServer.java | 49 ++++-- 2 files changed, 182 insertions(+), 49 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 44d40c48c2b8..e04744fcdb82 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 @@ -49,6 +49,7 @@ 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; import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint; import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast; @@ -97,6 +98,11 @@ class ActionsPopup { private String docTitle; private int docScroll; + private boolean showMcpLog; + private List<TuiMcpServer.LogEntry> mcpLogEntries; + private int mcpLogSelected; + private int mcpLogDetailScroll; + private final DoctorPopup doctorPopup = new DoctorPopup(); private final ClasspathPopup classpathPopup = new ClasspathPopup(); private final StopAllPopup stopAllPopup; @@ -137,7 +143,7 @@ class ActionsPopup { boolean isVisible() { return showActionsMenu || showExampleBrowser || runOptionsForm.isVisible() || showDocPicker || showDocViewer - || doctorPopup.isVisible() || classpathPopup.isVisible() + || showMcpLog || doctorPopup.isVisible() || classpathPopup.isVisible() || stopAllPopup.isVisible() || captionOverlay.isInputVisible(); } @@ -152,6 +158,7 @@ class ActionsPopup { runOptionsForm.close(); showDocPicker = false; showDocViewer = false; + showMcpLog = false; doctorPopup.close(); classpathPopup.close(); stopAllPopup.close(); @@ -167,6 +174,26 @@ class ActionsPopup { } boolean handleKeyEvent(KeyEvent ke) { + if (showMcpLog) { + if (ke.isCancel()) { + showMcpLog = false; + } else if (ke.isUp() || ke.isChar('k')) { + if (mcpLogEntries != null && !mcpLogEntries.isEmpty()) { + mcpLogSelected = Math.max(0, mcpLogSelected - 1); + mcpLogDetailScroll = 0; + } + } else if (ke.isDown() || ke.isChar('j')) { + if (mcpLogEntries != null && !mcpLogEntries.isEmpty()) { + mcpLogSelected = Math.min(mcpLogEntries.size() - 1, mcpLogSelected + 1); + mcpLogDetailScroll = 0; + } + } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + mcpLogDetailScroll = Math.max(0, mcpLogDetailScroll - 5); + } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + mcpLogDetailScroll += 5; + } + return true; + } if (showDocViewer) { if (ke.isCancel()) { showDocViewer = false; @@ -307,6 +334,9 @@ class ActionsPopup { if (showDocViewer) { renderDocViewer(frame, area); } + if (showMcpLog) { + renderMcpLog(frame, area); + } if (doctorPopup.isVisible()) { doctorPopup.render(frame, area); } @@ -338,6 +368,12 @@ class ActionsPopup { doctorPopup.renderFooter(spans); return; } + if (showMcpLog) { + hint(spans, "↑↓", "select"); + hint(spans, "PgUp/Dn", "scroll detail"); + hintLast(spans, "Esc", "back"); + return; + } if (showDocViewer) { hint(spans, "↑↓", "scroll"); hintLast(spans, "Esc", "back"); @@ -732,45 +768,113 @@ class ActionsPopup { } 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; + mcpLogEntries = mcpActivityLog != null ? mcpActivityLog.get() : List.of(); + mcpLogSelected = mcpLogEntries.isEmpty() ? 0 : mcpLogEntries.size() - 1; + mcpLogDetailScroll = 0; + showMcpLog = true; + } + + private void renderMcpLog(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); + + if (mcpLogEntries == null || mcpLogEntries.isEmpty()) { + Block block = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" MCP Log ") + .titleBottom(Title.from(Line.from( + Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back ")))) + .build(); + frame.renderWidget(block, popup); + Rect inner = block.inner(popup); + frame.renderWidget(Paragraph.from(Line.from( + Span.styled("No MCP activity yet.", Style.EMPTY.dim()))), inner); + return; + } + + int splitY = popup.top() + Math.max(3, (popup.height() * 2) / 5); + Rect masterArea = new Rect(popup.left(), popup.top(), popup.width(), splitY - popup.top()); + Rect detailArea = new Rect(popup.left(), splitY, popup.width(), popup.bottom() - splitY); + + // Master: log entry list + List<ListItem> items = new ArrayList<>(); + for (TuiMcpServer.LogEntry entry : mcpLogEntries) { + Style levelStyle = switch (entry.level()) { + case CONNECT -> Style.EMPTY.fg(Color.GREEN); + case TOOL -> Style.EMPTY.fg(Color.CYAN); + case ERROR -> Style.EMPTY.fg(Color.LIGHT_RED); + default -> Style.EMPTY.fg(Color.GREEN); + }; + String levelTag = switch (entry.level()) { + case CONNECT -> " CONNECT "; + case TOOL -> " TOOL "; + case ERROR -> " ERROR "; + default -> " INFO "; + }; + items.add(ListItem.from(Line.from( + Span.styled(entry.timestamp(), Style.EMPTY.dim()), + Span.styled(levelTag, levelStyle), + Span.raw(entry.message())))); + } + + ListState masterState = new ListState(); + masterState.select(mcpLogSelected); + ListWidget list = ListWidget.builder() + .items(items.toArray(ListItem[]::new)) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSymbol("▸ ") + .scrollMode(ScrollMode.AUTO_SCROLL) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .title(" MCP Log ") + .build()) + .build(); + frame.renderStatefulWidget(list, masterArea, masterState); + + // Detail: request + response JSON + TuiMcpServer.LogEntry selected = mcpLogEntries.get(mcpLogSelected); + List<Line> detailLines = new ArrayList<>(); + if (selected.requestBody() != null) { + detailLines.add(Line.from(Span.styled("▶ Request", Style.EMPTY.fg(Color.YELLOW).bold()))); + addJsonLines(detailLines, selected.requestBody()); + detailLines.add(Line.from(Span.raw(""))); + } + if (selected.responseBody() != null) { + detailLines.add(Line.from(Span.styled("◀ Response", Style.EMPTY.fg(Color.GREEN).bold()))); + addJsonLines(detailLines, selected.responseBody()); + } + if (selected.requestBody() == null && selected.responseBody() == null) { + detailLines.add(Line.from(Span.styled("(no request/response data)", Style.EMPTY.dim()))); + } + + Block detailBlock = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Detail ") + .titleBottom(Title.from(Line.from( + Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), Span.raw(" select │"), + Span.styled(" PgUp/Dn", MonitorContext.HINT_KEY_STYLE), Span.raw(" scroll │"), + Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back ")))) + .build(); + frame.renderWidget(detailBlock, detailArea); + Rect detailInner = detailBlock.inner(detailArea); + + int visibleLines = detailInner.height(); + int totalLines = detailLines.size(); + int clampedScroll = Math.min(mcpLogDetailScroll, Math.max(0, totalLines - visibleLines)); + int end = Math.min(clampedScroll + visibleLines, totalLines); + if (clampedScroll < end) { + List<Line> visible = detailLines.subList(clampedScroll, end); + frame.renderWidget( + Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(), + detailInner); + } + } + + private static void addJsonLines(List<Line> lines, String json) { + String pretty = Jsoner.prettyPrint(json, 2); + for (String line : pretty.split("\n", -1)) { + lines.add(Line.from(Span.styled(" " + line, Style.EMPTY.dim()))); } - docTitle = "MCP Log"; - docScroll = 0; - showDocViewer = true; - docViewerFromExampleBrowser = false; } private void openClasspath() { 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 914b22311f4e..d094c7a21757 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 @@ -62,7 +62,7 @@ class TuiMcpServer { ERROR } - record LogEntry(String timestamp, LogLevel level, String message) { + record LogEntry(String timestamp, LogLevel level, String message, String requestBody, String responseBody) { } private final int port; @@ -104,7 +104,11 @@ class TuiMcpServer { } private synchronized void log(LogLevel level, String message) { - activityLog.add(new LogEntry(TIME_FMT.format(Instant.now()), level, message)); + log(level, message, null, null); + } + + private synchronized void log(LogLevel level, String message, String requestBody, String responseBody) { + activityLog.add(new LogEntry(TIME_FMT.format(Instant.now()), level, message, requestBody, responseBody)); if (activityLog.size() > MAX_LOG_ENTRIES) { activityLog.remove(0); } @@ -160,7 +164,8 @@ class TuiMcpServer { if (result == null) { sendError(exchange, request, -32601, "Method not found: " + jsonrpcMethod); } else { - sendResult(exchange, request, result); + String responseJson = sendResult(exchange, request, result); + logMethodCall(jsonrpcMethod, request, body, responseJson); } } catch (Exception e) { exchange.sendResponseHeaders(500, -1); @@ -169,6 +174,30 @@ class TuiMcpServer { } } + private void logMethodCall(String jsonrpcMethod, JsonObject request, String requestBody, String responseBody) { + switch (jsonrpcMethod) { + case "initialize" -> { + JsonObject params = (JsonObject) request.get("params"); + String name = "unknown"; + if (params != null) { + JsonObject clientInfo = (JsonObject) params.get("clientInfo"); + if (clientInfo != null && clientInfo.get("name") != null) { + name = (String) clientInfo.get("name"); + } + } + log(LogLevel.CONNECT, "Client connected: " + name, requestBody, responseBody); + } + case "tools/call" -> { + JsonObject params = (JsonObject) request.get("params"); + String toolName = params != null ? (String) params.get("name") : "unknown"; + log(LogLevel.TOOL, "Tool call: " + toolName, requestBody, responseBody); + } + case "tools/list" -> log(LogLevel.INFO, "Tools listed", requestBody, responseBody); + case "ping" -> log(LogLevel.INFO, "Ping", requestBody, responseBody); + default -> log(LogLevel.INFO, jsonrpcMethod, requestBody, responseBody); + } + } + private JsonObject handleInitialize(JsonObject request) { JsonObject params = (JsonObject) request.get("params"); if (params != null) { @@ -177,7 +206,6 @@ class TuiMcpServer { clientName = (String) clientInfo.get("name"); } } - log(LogLevel.CONNECT, "Client connected: " + (clientName != null ? clientName : "unknown")); JsonObject result = new JsonObject(); result.put("protocolVersion", PROTOCOL_VERSION); @@ -222,7 +250,7 @@ class TuiMcpServer { Map.of("text", propDef("string", "The caption text to display"), "duration", propDef("integer", "Auto-dismiss after this many seconds. Caption won't block key events. " - + "If omitted, caption stays until dismissed by a key press.")), + + "If omitted, caption stays until dismissed by a key press.")), List.of("text"))); toolList.add(toolDef( "tui_navigate", @@ -258,7 +286,6 @@ class TuiMcpServer { } lastToolCallTime = System.currentTimeMillis(); - log(LogLevel.TOOL, "Tool call: " + toolName); String text; boolean isError = false; @@ -436,12 +463,12 @@ class TuiMcpServer { // --- JSON-RPC helpers --- - private void sendResult(HttpExchange exchange, JsonObject request, JsonObject result) throws IOException { + private String 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); + return sendJson(exchange, 200, response); } private void sendError(HttpExchange exchange, JsonObject request, int code, String message) throws IOException { @@ -456,13 +483,15 @@ class TuiMcpServer { sendJson(exchange, 200, response); } - private void sendJson(HttpExchange exchange, int status, JsonObject json) throws IOException { - byte[] bytes = Jsoner.serialize(json).getBytes(StandardCharsets.UTF_8); + private String sendJson(HttpExchange exchange, int status, JsonObject json) throws IOException { + String serialized = Jsoner.serialize(json); + byte[] bytes = serialized.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(status, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + return serialized; } // --- Tool definition helpers ---
