Skip to content

Commit f4c8733

Browse files
aksOpsclaude
andcommitted
Add MCP Console and Flow View UI pages (Tasks 7-8)
MCP Console: interactive terminal for executing MCP tools with command parsing, JSON output formatting, and help listing. Flow View: thin wrapper rendering Cytoscape flow diagrams with graceful fallback. Includes 12 tests for console parsing and argument coercion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dd35046 commit f4c8733

3 files changed

Lines changed: 317 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Flow View — wraps existing Cytoscape flow visualization."""
2+
from __future__ import annotations
3+
4+
from nicegui import ui
5+
6+
7+
def create_flow_page(service) -> None:
8+
"""Build the Flow tab inside a NiceGUI page.
9+
10+
Attempts to generate an overview flow diagram as HTML via the service.
11+
Falls back to a placeholder when no analysis data is available.
12+
"""
13+
try:
14+
result = service.generate_flow("overview", "html")
15+
16+
# generate_flow returns a dict; the HTML is in the "content" key
17+
html_content: str | None = None
18+
if isinstance(result, dict):
19+
html_content = result.get("content") or result.get("html")
20+
elif isinstance(result, str):
21+
html_content = result
22+
23+
if html_content:
24+
ui.html(html_content).classes("w-full")
25+
else:
26+
_show_placeholder()
27+
28+
except Exception: # noqa: BLE001
29+
_show_placeholder()
30+
31+
32+
def _show_placeholder() -> None:
33+
"""Show a friendly placeholder when no flow data is available."""
34+
with ui.column().classes("w-full items-center justify-center py-16"):
35+
ui.icon("account_tree", size="64px").classes("text-gray-400")
36+
ui.label("No flow data available.").classes("text-xl text-gray-500 mt-4")
37+
ui.label("Run 'osscodeiq analyze' first.").classes(
38+
"text-sm text-gray-400 mt-1"
39+
)
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tests for the OSSCodeIQ MCP Console module."""
2+
from __future__ import annotations
3+
4+
from osscodeiq.server.ui.mcp_console import (
5+
MCP_TOOL_NAMES,
6+
_coerce_arg,
7+
parse_mcp_command,
8+
)
9+
10+
11+
class TestMCPToolNames:
12+
def test_mcp_tool_names_populated(self) -> None:
13+
assert "get_stats" in MCP_TOOL_NAMES
14+
assert "search_graph" in MCP_TOOL_NAMES
15+
assert len(MCP_TOOL_NAMES) >= 18
16+
17+
18+
class TestParseMcpCommand:
19+
def test_parse_mcp_command_simple(self) -> None:
20+
tool, kwargs = parse_mcp_command("get_stats")
21+
assert tool == "get_stats"
22+
assert kwargs == {}
23+
24+
def test_parse_mcp_command_with_args(self) -> None:
25+
tool, kwargs = parse_mcp_command('search_graph query="auth" limit=10')
26+
assert tool == "search_graph"
27+
assert kwargs["query"] == "auth"
28+
assert kwargs["limit"] == 10
29+
30+
def test_parse_mcp_command_empty(self) -> None:
31+
tool, kwargs = parse_mcp_command("")
32+
assert tool == ""
33+
assert kwargs == {}
34+
35+
def test_parse_mcp_command_whitespace_only(self) -> None:
36+
tool, kwargs = parse_mcp_command(" ")
37+
assert tool == ""
38+
assert kwargs == {}
39+
40+
def test_parse_mcp_command_unquoted_string_arg(self) -> None:
41+
tool, kwargs = parse_mcp_command("find_callers target_id=some:node:id")
42+
assert tool == "find_callers"
43+
assert kwargs["target_id"] == "some:node:id"
44+
45+
def test_parse_mcp_command_multiple_quoted(self) -> None:
46+
tool, kwargs = parse_mcp_command(
47+
'find_shortest_path source="node:a" target="node:b"'
48+
)
49+
assert tool == "find_shortest_path"
50+
assert kwargs["source"] == "node:a"
51+
assert kwargs["target"] == "node:b"
52+
53+
54+
class TestCoerceArg:
55+
def test_coerce_arg_int(self) -> None:
56+
assert _coerce_arg("10") == 10
57+
58+
def test_coerce_arg_string(self) -> None:
59+
assert _coerce_arg("hello") == "hello"
60+
61+
def test_coerce_arg_negative_int(self) -> None:
62+
assert _coerce_arg("-5") == -5
63+
64+
def test_coerce_arg_zero(self) -> None:
65+
assert _coerce_arg("0") == 0
66+
67+
def test_coerce_arg_float_stays_string(self) -> None:
68+
result = _coerce_arg("3.14")
69+
assert result == "3.14"
70+
assert isinstance(result, str)

0 commit comments

Comments
 (0)