|
| 1 | +"""MCP Tool Console — interactive terminal for executing MCP tools.""" |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import json |
| 5 | +import re |
| 6 | +from typing import Any |
| 7 | + |
| 8 | +from nicegui import ui |
| 9 | + |
| 10 | +MCP_TOOL_NAMES: list[str] = [ |
| 11 | + "get_stats", |
| 12 | + "query_nodes", |
| 13 | + "query_edges", |
| 14 | + "get_node_neighbors", |
| 15 | + "get_ego_graph", |
| 16 | + "find_cycles", |
| 17 | + "find_shortest_path", |
| 18 | + "find_consumers", |
| 19 | + "find_producers", |
| 20 | + "find_callers", |
| 21 | + "find_dependencies", |
| 22 | + "find_dependents", |
| 23 | + "generate_flow", |
| 24 | + "find_component_by_file", |
| 25 | + "trace_impact", |
| 26 | + "find_related_endpoints", |
| 27 | + "search_graph", |
| 28 | + "read_file", |
| 29 | +] |
| 30 | + |
| 31 | +_ARG_RE = re.compile(r'(\w+)=(?:"([^"]*)"|([\S]+))') |
| 32 | + |
| 33 | + |
| 34 | +def _coerce_arg(val: str) -> int | str: |
| 35 | + """Try to cast *val* to int, otherwise return the string unchanged.""" |
| 36 | + try: |
| 37 | + return int(val) |
| 38 | + except (ValueError, TypeError): |
| 39 | + return val |
| 40 | + |
| 41 | + |
| 42 | +def parse_mcp_command(raw: str) -> tuple[str, dict[str, Any]]: |
| 43 | + """Parse a command string into (tool_name, kwargs). |
| 44 | +
|
| 45 | + Format:: |
| 46 | +
|
| 47 | + tool_name key1="value1" key2=value2 |
| 48 | +
|
| 49 | + Returns ``("", {})`` for empty / blank input. |
| 50 | + """ |
| 51 | + raw = raw.strip() |
| 52 | + if not raw: |
| 53 | + return ("", {}) |
| 54 | + |
| 55 | + parts = raw.split(None, 1) |
| 56 | + tool_name = parts[0] |
| 57 | + kwargs: dict[str, Any] = {} |
| 58 | + |
| 59 | + if len(parts) > 1: |
| 60 | + for match in _ARG_RE.finditer(parts[1]): |
| 61 | + key = match.group(1) |
| 62 | + # group(2) is the quoted value, group(3) the unquoted value |
| 63 | + value = match.group(2) if match.group(2) is not None else match.group(3) |
| 64 | + kwargs[key] = _coerce_arg(value) |
| 65 | + |
| 66 | + return (tool_name, kwargs) |
| 67 | + |
| 68 | + |
| 69 | +# ── MCP tool lookup table ────────────────────────────────────────────────── |
| 70 | + |
| 71 | + |
| 72 | +def _get_tool_fn(name: str): |
| 73 | + """Import and return the MCP tool function by *name*, or None.""" |
| 74 | + from osscodeiq.server.mcp_server import ( # noqa: C0415 |
| 75 | + find_callers, |
| 76 | + find_component_by_file, |
| 77 | + find_consumers, |
| 78 | + find_cycles, |
| 79 | + find_dependencies, |
| 80 | + find_dependents, |
| 81 | + find_related_endpoints, |
| 82 | + find_shortest_path, |
| 83 | + generate_flow, |
| 84 | + get_ego_graph, |
| 85 | + get_node_neighbors, |
| 86 | + get_stats, |
| 87 | + query_edges, |
| 88 | + query_nodes, |
| 89 | + read_file, |
| 90 | + search_graph, |
| 91 | + trace_impact, |
| 92 | + ) |
| 93 | + |
| 94 | + _TOOL_MAP: dict[str, Any] = { |
| 95 | + "get_stats": get_stats, |
| 96 | + "query_nodes": query_nodes, |
| 97 | + "query_edges": query_edges, |
| 98 | + "get_node_neighbors": get_node_neighbors, |
| 99 | + "get_ego_graph": get_ego_graph, |
| 100 | + "find_cycles": find_cycles, |
| 101 | + "find_shortest_path": find_shortest_path, |
| 102 | + "find_consumers": find_consumers, |
| 103 | + "find_producers": find_producers, # noqa: F841 — not importable separately |
| 104 | + "find_callers": find_callers, |
| 105 | + "find_dependencies": find_dependencies, |
| 106 | + "find_dependents": find_dependents, |
| 107 | + "generate_flow": generate_flow, |
| 108 | + "find_component_by_file": find_component_by_file, |
| 109 | + "trace_impact": trace_impact, |
| 110 | + "find_related_endpoints": find_related_endpoints, |
| 111 | + "search_graph": search_graph, |
| 112 | + "read_file": read_file, |
| 113 | + } |
| 114 | + |
| 115 | + # find_producers is imported via find_consumers' sibling — fix the map |
| 116 | + from osscodeiq.server.mcp_server import find_producers # noqa: C0415 |
| 117 | + |
| 118 | + _TOOL_MAP["find_producers"] = find_producers |
| 119 | + |
| 120 | + return _TOOL_MAP.get(name) |
| 121 | + |
| 122 | + |
| 123 | +# ── Console builder ──────────────────────────────────────────────────────── |
| 124 | + |
| 125 | + |
| 126 | +def create_mcp_console(service) -> None: # noqa: ARG001 — service kept for API parity |
| 127 | + """Build the MCP Console tab inside a NiceGUI page.""" |
| 128 | + |
| 129 | + ui.label("MCP Tool Console").classes("text-xl font-bold") |
| 130 | + ui.label("Execute MCP tools interactively").classes("text-sm text-gray-500") |
| 131 | + |
| 132 | + scroll = ui.scroll_area().classes("w-full border rounded").style("height: 480px") |
| 133 | + |
| 134 | + # Seed welcome message |
| 135 | + with scroll: |
| 136 | + output_col = ui.column().classes("w-full gap-1 p-2") |
| 137 | + |
| 138 | + with output_col: |
| 139 | + ui.label("Welcome to the MCP Tool Console.").classes("font-mono text-sm") |
| 140 | + ui.label('Type a tool name and arguments, or "help" to list tools.').classes( |
| 141 | + "font-mono text-sm text-gray-500" |
| 142 | + ) |
| 143 | + |
| 144 | + # ── input row ─────────────────────────────────────────────────────── |
| 145 | + with ui.row().classes("w-full items-center gap-2 mt-2"): |
| 146 | + ui.label("$").classes("font-mono text-lg") |
| 147 | + cmd_input = ui.input(placeholder="get_stats").classes("flex-grow font-mono") |
| 148 | + run_btn = ui.button("Run") |
| 149 | + |
| 150 | + # ── handler ───────────────────────────────────────────────────────── |
| 151 | + |
| 152 | + async def _execute() -> None: |
| 153 | + raw = cmd_input.value or "" |
| 154 | + raw = raw.strip() |
| 155 | + if not raw: |
| 156 | + return |
| 157 | + |
| 158 | + # Echo command |
| 159 | + with output_col: |
| 160 | + ui.label(f"$ {raw}").classes("font-mono text-sm font-bold mt-2") |
| 161 | + |
| 162 | + cmd_input.value = "" |
| 163 | + |
| 164 | + # Handle built-in "help" |
| 165 | + if raw.lower() == "help": |
| 166 | + with output_col: |
| 167 | + ui.label("Available tools:").classes("font-mono text-sm mt-1") |
| 168 | + for name in sorted(MCP_TOOL_NAMES): |
| 169 | + ui.label(f" {name}").classes("font-mono text-sm text-blue-600") |
| 170 | + scroll.scroll_to(percent=1.0) |
| 171 | + return |
| 172 | + |
| 173 | + tool_name, kwargs = parse_mcp_command(raw) |
| 174 | + |
| 175 | + fn = _get_tool_fn(tool_name) |
| 176 | + if fn is None: |
| 177 | + with output_col: |
| 178 | + ui.label(f"Unknown tool: {tool_name}").classes( |
| 179 | + "font-mono text-sm text-red-600" |
| 180 | + ) |
| 181 | + scroll.scroll_to(percent=1.0) |
| 182 | + return |
| 183 | + |
| 184 | + try: |
| 185 | + result = fn(**kwargs) |
| 186 | + |
| 187 | + # MCP tools return JSON strings — parse and re-format |
| 188 | + try: |
| 189 | + parsed = json.loads(result) |
| 190 | + formatted = json.dumps(parsed, indent=2) |
| 191 | + except (json.JSONDecodeError, TypeError): |
| 192 | + formatted = str(result) |
| 193 | + |
| 194 | + with output_col: |
| 195 | + pre = ui.element("pre").classes( |
| 196 | + "font-mono text-sm bg-gray-50 p-2 rounded overflow-x-auto whitespace-pre-wrap" |
| 197 | + ) |
| 198 | + with pre: |
| 199 | + ui.label(formatted).classes("font-mono text-sm") |
| 200 | + |
| 201 | + except Exception as exc: # noqa: BLE001 |
| 202 | + with output_col: |
| 203 | + ui.label(f"Error: {exc}").classes("font-mono text-sm text-red-600") |
| 204 | + |
| 205 | + scroll.scroll_to(percent=1.0) |
| 206 | + |
| 207 | + run_btn.on_click(_execute) |
| 208 | + cmd_input.on("keydown.enter", _execute) |
0 commit comments