diff --git a/.github/workflows/roam-ci.yml b/.github/workflows/roam-ci.yml index 11f0bc08..c2babb09 100644 --- a/.github/workflows/roam-ci.yml +++ b/.github/workflows/roam-ci.yml @@ -49,4 +49,4 @@ jobs: - run: pip install -e . - run: roam index - run: roam fitness - - run: roam health --json + - run: roam --json health diff --git a/.github/workflows/roam.yml b/.github/workflows/roam.yml index 89b53962..45c9c931 100644 --- a/.github/workflows/roam.yml +++ b/.github/workflows/roam.yml @@ -18,4 +18,4 @@ jobs: - run: pip install roam-code - run: roam index - run: roam fitness - - run: roam pr-risk --json + - run: roam --json pr-risk diff --git a/CLAUDE.md b/CLAUDE.md index 2e473786..f42a9e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ roam health ``` src/roam/ cli.py # Click CLI entry point — LazyGroup, _COMMANDS dict, _CATEGORIES - mcp_server.py # FastMCP server (48 tools, 2 resources) + mcp_server.py # FastMCP server (61 tools, 2 resources) + `roam mcp` CLI command __init__.py # Version string (reads from pyproject.toml via importlib.metadata) db/ schema.py # SQLite schema (CREATE TABLE statements) @@ -106,7 +106,7 @@ src/roam/ formatter.py # Token-efficient text formatting, abbrev_kind(), loc(), format_table(), to_json(), json_envelope() sarif.py # SARIF 2.1.0 output (--sarif flag on health/debt/complexity) schema_registry.py # JSON envelope schema versioning + validation -tests/ # 70 test files +tests/ # 71 test files # Core & legacy test_basic.py, test_comprehensive.py, test_fixes.py, test_performance.py, test_resolve.py, test_salesforce.py, test_v6_features.py, @@ -130,7 +130,8 @@ tests/ # 70 test files test_capsule.py, test_forecast.py, test_path_coverage.py, test_minimap.py, test_attest.py, test_annotations.py, test_budget.py, test_pr_diff.py, test_framework_detection.py, test_backend_fixes_round2.py, - test_backend_fixes_round3.py, test_exclude_patterns.py, test_math_tips.py + test_backend_fixes_round3.py, test_exclude_patterns.py, test_math_tips.py, + test_mcp_server.py ``` ### Key patterns @@ -223,7 +224,7 @@ tests/ # 70 test files - tree-sitter >= 0.23 (AST parsing) - tree-sitter-language-pack >= 0.6 (165+ grammars) - networkx >= 3.0 (graph algorithms) -- Optional: fastmcp (MCP server) +- Optional: fastmcp >= 2.0 (MCP server — `pip install roam-code[mcp]`) - Dev: pytest >= 7.0, pytest-xdist >= 3.0, ruff >= 0.4 ## Version bumping diff --git a/README.md b/README.md index d72fb763..c1d97686 100644 --- a/README.md +++ b/README.md @@ -722,73 +722,86 @@ Run `roam --help` for all commands. Use `roam --json ` for structured outpu Roam includes a [Model Context Protocol](https://modelcontextprotocol.io/) server for direct integration with tools that support MCP. ```bash -pip install fastmcp -fastmcp run roam.mcp_server:mcp +pip install roam-code[mcp] +roam mcp ``` -48 read-only tools and 2 resources. All tools query the index -- they never modify your code. +61 tools and 2 resources. All tools are read-only and query the index -- they never modify your code. -**Lite mode:** For smaller models or latency-sensitive setups, set `ROAM_MCP_LITE=1` to expose only 15 core tools: +**Lite mode (default):** By default, 16 core tools are exposed to keep the tool list manageable for agents. Set `ROAM_MCP_LITE=0` to expose all 61 tools: ```bash -ROAM_MCP_LITE=1 fastmcp run roam.mcp_server:mcp +ROAM_MCP_LITE=0 roam mcp ``` -Core tools in lite mode: `understand`, `health`, `preflight`, `search_symbol`, `context`, `trace`, `impact`, `file_info`, `pr_risk`, `affected_tests`, `dead_code`, `complexity_report`, `diagnose`, `visualize`, `closure`. +Core tools in lite mode: `roam_understand`, `roam_search_symbol`, `roam_context`, `roam_file_info`, `roam_deps`, `roam_preflight`, `roam_diff`, `roam_pr_risk`, `roam_affected_tests`, `roam_impact`, `roam_uses`, `roam_health`, `roam_dead_code`, `roam_complexity_report`, `roam_diagnose`, `roam_trace`.
-MCP tool list (all 48) +MCP tool list (all 61) | Tool | Description | |------|-------------| -| `understand` | Full codebase briefing | -| `health` | Health score (0-100) + issues | -| `preflight` | Pre-change safety check | -| `search_symbol` | Find symbols by name | -| `context` | Files-to-read for modifying a symbol | -| `trace` | Dependency path between two symbols | -| `impact` | Blast radius of changing a symbol | -| `file_info` | File skeleton with all definitions | -| `pr_risk` | Risk score for pending changes | -| `breaking_changes` | Detect breaking changes between refs | -| `affected_tests` | Find tests affected by a change | -| `dead_code` | List unreferenced exports | -| `complexity_report` | Per-symbol cognitive complexity | -| `repo_map` | Project skeleton with key symbols | -| `tour` | Auto-generated onboarding guide | -| `diagnose` | Root cause analysis for debugging | -| `visualize` | Generate Mermaid or DOT architecture diagrams | -| `algo` | Algorithm anti-pattern detection with language-aware tips | -| `ws_understand` | Unified multi-repo workspace overview | -| `ws_context` | Cross-repo augmented symbol context | -| `pr_diff` | Structural PR diff: metric deltas, edge analysis, symbol changes | -| `budget_check` | Check changes against architectural budgets | -| `effects` | Side-effect classification (DB writes, network, filesystem) | -| `attest` | Proof-carrying PR attestation with all evidence bundled | -| `capsule_export` | Export sanitized structural graph (no code bodies) | -| `path_coverage` | Find critical untested call paths (entry -> sink) | -| `forecast` | Predict when metrics will exceed thresholds | -| `simulate` | Counterfactual architecture simulator | -| `orchestrate` | Multi-agent swarm partitioning | -| `fingerprint` | Topology fingerprint comparison | -| `mutate` | Graph-level code editing (move/rename/extract) | -| `dark_matter` | Hidden co-change coupling detection | -| `closure` | Minimal-change synthesis for rename/delete | -| `adversarial_review` | Adversarial architecture review | -| `generate_plan` | Agent work planner | -| `get_invariants` | Architectural invariant discovery | -| `bisect_blame` | Architectural git bisect | -| `doc_intent` | Doc-to-code linking | -| `cut_analysis` | Minimum graph cut analysis | -| `annotate_symbol` | Attach persistent notes to symbols | -| `get_annotations` | View stored annotations | -| `relate` | Show relationship between two symbols | -| `search_semantic` | Semantic search by meaning | -| `rules_check` | Plugin DSL governance rules | -| `vuln_map` | Vulnerability report ingestion | -| `vuln_reach` | Vulnerability reachability paths | -| `ingest_trace` | Ingest runtime trace data | -| `runtime_hotspots` | Runtime hotspot analysis | +| `roam_understand` | Full codebase briefing | +| `roam_health` | Health score (0-100) + issues | +| `roam_preflight` | Pre-change safety check | +| `roam_search_symbol` | Find symbols by name | +| `roam_context` | Files-to-read for modifying a symbol | +| `roam_trace` | Dependency path between two symbols | +| `roam_impact` | Blast radius of changing a symbol | +| `roam_file_info` | File skeleton with all definitions | +| `roam_pr_risk` | Risk score for pending changes | +| `roam_breaking_changes` | Detect breaking changes between refs | +| `roam_affected_tests` | Find tests affected by a change | +| `roam_dead_code` | List unreferenced exports | +| `roam_complexity_report` | Per-symbol cognitive complexity | +| `roam_repo_map` | Project skeleton with key symbols | +| `roam_tour` | Auto-generated onboarding guide | +| `roam_diagnose` | Root cause analysis for debugging | +| `roam_visualize` | Generate Mermaid or DOT architecture diagrams | +| `roam_algo` | Algorithm anti-pattern detection with language-aware tips | +| `roam_ws_understand` | Unified multi-repo workspace overview | +| `roam_ws_context` | Cross-repo augmented symbol context | +| `roam_pr_diff` | Structural PR diff: metric deltas, edge analysis, symbol changes | +| `roam_budget_check` | Check changes against architectural budgets | +| `roam_effects` | Side-effect classification (DB writes, network, filesystem) | +| `roam_attest` | Proof-carrying PR attestation with all evidence bundled | +| `roam_capsule_export` | Export sanitized structural graph (no code bodies) | +| `roam_path_coverage` | Find critical untested call paths (entry -> sink) | +| `roam_forecast` | Predict when metrics will exceed thresholds | +| `roam_simulate` | Counterfactual architecture simulator | +| `roam_orchestrate` | Multi-agent swarm partitioning | +| `roam_fingerprint` | Topology fingerprint comparison | +| `roam_mutate` | Graph-level code editing (move/rename/extract) | +| `roam_dark_matter` | Hidden co-change coupling detection | +| `roam_closure` | Minimal-change synthesis for rename/delete | +| `roam_adversarial_review` | Adversarial architecture review | +| `roam_generate_plan` | Agent work planner | +| `roam_get_invariants` | Architectural invariant discovery | +| `roam_bisect_blame` | Architectural git bisect | +| `roam_doc_intent` | Doc-to-code linking | +| `roam_cut_analysis` | Minimum graph cut analysis | +| `roam_annotate_symbol` | Attach persistent notes to symbols | +| `roam_get_annotations` | View stored annotations | +| `roam_relate` | Show relationship between two symbols | +| `roam_search_semantic` | Semantic search by meaning | +| `roam_rules_check` | Plugin DSL governance rules | +| `roam_vuln_map` | Vulnerability report ingestion | +| `roam_vuln_reach` | Vulnerability reachability paths | +| `roam_ingest_trace` | Ingest runtime trace data | +| `roam_runtime_hotspots` | Runtime hotspot analysis | +| `roam_diff` | Blast radius of uncommitted/committed changes | +| `roam_symbol` | Symbol definition, callers, callees, metrics | +| `roam_deps` | File-level import/imported-by relationships | +| `roam_uses` | All consumers of a symbol by edge type | +| `roam_weather` | Code hotspots: churn x complexity ranking | +| `roam_debt` | Hotspot-weighted technical debt prioritization | +| `roam_n1` | Detect N+1 I/O patterns in ORM code | +| `roam_auth_gaps` | Find endpoints missing auth | +| `roam_over_fetch` | Detect models serializing too many fields | +| `roam_missing_index` | Find queries on non-indexed columns | +| `roam_orphan_routes` | Detect dead backend routes | +| `roam_migration_safety` | Detect non-idempotent migrations | +| `roam_api_drift` | Backend/frontend model mismatch detection | **Resources:** `roam://health` (current health score), `roam://summary` (project overview) @@ -798,7 +811,7 @@ Core tools in lite mode: `understand`, `health`, `preflight`, `search_symbol`, ` Claude Code ```bash -claude mcp add roam -- fastmcp run roam.mcp_server:mcp +claude mcp add roam-code -- roam mcp ``` Or add to `.mcp.json` in your project root: @@ -806,9 +819,9 @@ Or add to `.mcp.json` in your project root: ```json { "mcpServers": { - "roam": { - "command": "fastmcp", - "args": ["run", "roam.mcp_server:mcp"] + "roam-code": { + "command": "roam", + "args": ["mcp"] } } } @@ -824,9 +837,9 @@ Add to your `claude_desktop_config.json`: ```json { "mcpServers": { - "roam": { - "command": "fastmcp", - "args": ["run", "roam.mcp_server:mcp"], + "roam-code": { + "command": "roam", + "args": ["mcp"], "cwd": "/path/to/your/project" } } @@ -843,9 +856,9 @@ Add to `.cursor/mcp.json`: ```json { "mcpServers": { - "roam": { - "command": "fastmcp", - "args": ["run", "roam.mcp_server:mcp"] + "roam-code": { + "command": "roam", + "args": ["mcp"] } } } @@ -861,10 +874,10 @@ Add to `.vscode/mcp.json`: ```json { "servers": { - "roam": { + "roam-code": { "type": "stdio", - "command": "fastmcp", - "args": ["run", "roam.mcp_server:mcp"] + "command": "roam", + "args": ["mcp"] } } } @@ -1167,7 +1180,7 @@ Roam is **not** a replacement for your linter, LSP, or SonarQube. It fills a dif | **Aider repo map** | Tree-sitter + PageRank | Context selection for chat. Roam adds git signals, 95 architecture commands, CI gates, multi-agent orchestration | | **CodeScene** | Behavioral code analysis | Commercial SaaS ($20-60k/yr). Roam is free, local, uses peer-reviewed algorithms (Mann-Kendall, NPMI, Personalized PageRank) | | **SonarQube** | Code quality + security | Heavy server ($15-45k/yr). Roam's cognitive complexity follows SonarSource spec | -| **Serena MCP** | LSP-based symbol navigation | 6 MCP tools for navigation. Roam has 48 MCP tools covering architecture, governance, simulation, and orchestration | +| **Serena MCP** | LSP-based symbol navigation | 6 MCP tools for navigation. Roam has 61 MCP tools covering architecture, governance, simulation, and orchestration | | **Repomix / code2prompt** | Codebase packing for LLMs | Flat file packing with no graph intelligence. Roam gives structural queries, not raw file dumps | | **Augment Code** | Cloud context engine | Cloud-hosted, enterprise-priced. Roam is 100% local, air-gapped, MIT-licensed | | **grep / ripgrep** | Text search | No semantic understanding. Can't distinguish definitions from usage | @@ -1254,7 +1267,7 @@ roam-code/ ├── src/roam/ │ ├── __init__.py # Version (from pyproject.toml) │ ├── cli.py # Click CLI (95 commands, 7 categories) -│ ├── mcp_server.py # MCP server (48 tools, 2 resources) +│ ├── mcp_server.py # MCP server (61 tools, 2 resources) │ ├── db/ │ │ ├── connection.py # SQLite (WAL, pragmas, batched IN) │ │ ├── schema.py # Tables, indexes, migrations @@ -1333,7 +1346,7 @@ roam-code/ | [tree-sitter-language-pack](https://github.com/nicolo-ribaudo/tree-sitter-language-pack) >= 0.6 | 165+ grammars | | [networkx](https://networkx.org/) >= 3.0 | Graph algorithms | -Optional: [fastmcp](https://github.com/jlowin/fastmcp) (MCP server) +Optional: [fastmcp](https://github.com/jlowin/fastmcp) >= 2.0 (MCP server — install with `pip install roam-code[mcp]`) ## Roadmap diff --git a/llms-install.md b/llms-install.md index 8e9f88a0..38b0200b 100644 --- a/llms-install.md +++ b/llms-install.md @@ -1,12 +1,13 @@ # Installing roam-code roam-code provides instant codebase comprehension for AI coding agents. -55 commands, 22 languages, 100% local, zero API keys. +95 commands, 26 languages, 100% local, zero API keys. ## Quick install ```bash pip install roam-code +pip install roam-code[mcp] # optional: MCP server support ``` Or with isolated environments: @@ -71,4 +72,4 @@ Add to your MCP config: | `roam context ` | Files and line ranges to read | | `roam diff` | Blast radius of uncommitted changes | -Run `roam --help` for all 55 commands. +Run `roam --help` for all 95 commands. diff --git a/pyproject.toml b/pyproject.toml index 428723bb..14912dc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,9 @@ Issues = "https://github.com/Cranot/roam-code/issues" roam = "roam.cli:cli" [project.optional-dependencies] +mcp = [ + "fastmcp>=2.0", +] dev = [ "pytest>=7.0", "pytest-xdist>=3.0", diff --git a/src/roam/cli.py b/src/roam/cli.py index 84b009c0..076b2408 100644 --- a/src/roam/cli.py +++ b/src/roam/cli.py @@ -109,11 +109,12 @@ "schema": ("roam.commands.cmd_schema", "schema_cmd"), "search-semantic": ("roam.commands.cmd_search_semantic", "search_semantic"), "relate": ("roam.commands.cmd_relate", "relate"), + "mcp": ("roam.mcp_server", "mcp_cmd"), } # Command categories for organized --help display _CATEGORIES = { - "Getting Started": ["index", "init", "config", "understand", "tour", "describe", "minimap", "ws", "schema"], + "Getting Started": ["index", "init", "config", "understand", "tour", "describe", "minimap", "ws", "schema", "mcp"], "Daily Workflow": ["preflight", "pr-risk", "pr-diff", "attest", "adversarial", "diff", "context", "affected-tests", "diagnose", "digest", "annotate", "annotations", "plan"], "Codebase Health": ["health", "weather", "debt", "complexity", "algo", "n1", "over-fetch", "missing-index", "alerts", "trend", "fitness", "snapshot", "forecast", "bisect", "ingest-trace", "hotspots"], "Architecture": ["map", "layers", "clusters", "coupling", "dark-matter", "effects", "cut", "simulate", "orchestrate", "entry-points", "patterns", "safe-zones", "visualize", "x-lang", "fingerprint"], diff --git a/src/roam/mcp_server.py b/src/roam/mcp_server.py index 89e41ccb..62f6b1c3 100644 --- a/src/roam/mcp_server.py +++ b/src/roam/mcp_server.py @@ -5,9 +5,8 @@ and change-risk through a standard tool interface. Usage: - python -m roam.mcp_server - # or - fastmcp run roam.mcp_server:mcp + roam mcp # stdio (for Claude Code, Cursor, etc.) + roam mcp --transport sse # SSE on localhost:8000 """ from __future__ import annotations @@ -15,55 +14,106 @@ import json import os import subprocess +import sys + +import click try: from fastmcp import FastMCP except ImportError: - raise ImportError( - "fastmcp is required for the roam MCP server. " - "Install it with: pip install fastmcp" - ) + FastMCP = None # --------------------------------------------------------------------------- -# Lite mode: expose only ~15 core tools when ROAM_MCP_LITE=1 +# Lite mode (default): expose only core tools for better agent tool selection. +# Set ROAM_MCP_LITE=0 to expose all tools. # --------------------------------------------------------------------------- -_LITE = os.environ.get("ROAM_MCP_LITE", "").lower() in ("1", "true", "yes") +_LITE = os.environ.get("ROAM_MCP_LITE", "1").lower() not in ("0", "false", "no") _CORE_TOOLS = { - "understand", "health", "preflight", "search_symbol", "context", - "trace", "impact", "file_info", "pr_risk", "affected_tests", - "dead_code", "complexity_report", "diagnose", "visualize", "closure", + # comprehension (5) + "roam_understand", "roam_search_symbol", "roam_context", "roam_file_info", "roam_deps", + # daily workflow (6) + "roam_preflight", "roam_diff", "roam_pr_risk", "roam_affected_tests", "roam_impact", "roam_uses", + # code quality (5) + "roam_health", "roam_dead_code", "roam_complexity_report", "roam_diagnose", "roam_trace", } -def _tool(): - """MCP tool decorator. In lite mode (ROAM_MCP_LITE=1), only core tools register.""" +# --------------------------------------------------------------------------- +# Server instance +# --------------------------------------------------------------------------- + +if FastMCP is not None: + mcp = FastMCP( + "roam-code", + instructions=( + "Codebase intelligence for AI coding agents. " + "Pre-indexes symbols, call graphs, dependencies, architecture, " + "and git history into a local SQLite DB. " + "One tool call replaces 5-10 Glob/Grep/Read calls. " + "All tools are read-only and safe to call freely." + ), + ) +else: + mcp = None + + +_REGISTERED_TOOLS: list[str] = [] + + +def _tool(name: str): + """Register an MCP tool. In lite mode, only _CORE_TOOLS are registered.""" def decorator(fn): - if _LITE and fn.__name__ not in _CORE_TOOLS: + if mcp is None: + return fn + if _LITE and name not in _CORE_TOOLS: return fn - return mcp.tool()(fn) + _REGISTERED_TOOLS.append(name) + return mcp.tool(name=name)(fn) return decorator + # --------------------------------------------------------------------------- -# Server instance +# Internal helpers # --------------------------------------------------------------------------- -mcp = FastMCP( - "roam-code", - description=( - "Codebase intelligence for AI coding agents. " - "Pre-indexes symbols, call graphs, dependencies, architecture, " - "and git history into a local SQLite DB. " - "One tool call replaces 5-10 Glob/Grep/Read calls. " - "All tools are read-only and safe to call freely." - ), -) +_ERROR_PATTERNS: list[tuple[str, str, str]] = [ + # (pattern, error_code, hint) — checked in order, first match wins. + # More specific patterns MUST come before broader ones. + ("no .roam", "INDEX_NOT_FOUND", "run `roam init` to create the codebase index."), + ("not found in index", "INDEX_NOT_FOUND", "run `roam init` to create the codebase index."), + ("index is stale", "INDEX_STALE", "run `roam index` to refresh."), + ("out of date", "INDEX_STALE", "run `roam index` to refresh."), + ("not a git repository","NOT_GIT_REPO", "some commands require git history. run: git init."), + ("database is locked", "DB_LOCKED", "another roam process is running. wait or delete .roam/index.lock."), + ("permission denied", "PERMISSION_DENIED","check file permissions."), + ("cannot open index", "INDEX_NOT_FOUND", "run `roam init` to create the codebase index."), + ("symbol not found", "NO_RESULTS", "try a different search term or check spelling."), + ("no matches", "NO_RESULTS", "try a different search term or check spelling."), + ("no results", "NO_RESULTS", "try a different search term or check spelling."), +] + + +def _classify_error(stderr: str, exit_code: int) -> tuple[str, str]: + """Classify error and return (error_code, hint).""" + s = stderr.lower() + for pattern, code, hint in _ERROR_PATTERNS: + if pattern in s: + return (code, hint) + if exit_code != 0: + return ("COMMAND_FAILED", "check arguments and try again.") + return ("UNKNOWN", "check the error message for details.") + + +def _ensure_fresh_index(root: str = ".") -> dict | None: + """Run incremental index to ensure freshness. Returns None on success.""" + result = _run_roam(["index"], root) + if "error" in result: + return {"error": f"index update failed: {result['error']}"} + return None -# --------------------------------------------------------------------------- -# Internal helper -# --------------------------------------------------------------------------- def _run_roam(args: list[str], root: str = ".") -> dict: """Run a roam CLI command with ``--json`` and return parsed output. @@ -92,9 +142,14 @@ def _run_roam(args: list[str], root: str = ".") -> dict: ) if result.returncode == 0 and result.stdout.strip(): return json.loads(result.stdout) + stderr = result.stderr.strip() + error_code, hint = _classify_error(stderr, result.returncode) return { - "error": result.stderr.strip() or "Command failed", + "error": stderr or "command failed", + "error_code": error_code, + "hint": hint, "exit_code": result.returncode, + "command": " ".join(cmd), } except subprocess.TimeoutExpired: return {"error": "Command timed out after 60s"} @@ -109,7 +164,7 @@ def _run_roam(args: list[str], root: str = ".") -> dict: # =================================================================== -@_tool() +@_tool(name="roam_understand") def understand(root: str = ".") -> dict: """Get a full codebase briefing in a single call. @@ -127,7 +182,7 @@ def understand(root: str = ".") -> dict: return _run_roam(["understand"], root) -@_tool() +@_tool(name="roam_health") def health(root: str = ".") -> dict: """Get the codebase health score (0-100) with issue breakdown. @@ -143,7 +198,7 @@ def health(root: str = ".") -> dict: return _run_roam(["health"], root) -@_tool() +@_tool(name="roam_preflight") def preflight(target: str = "", staged: bool = False, root: str = ".") -> dict: """Pre-change safety check. Call this BEFORE modifying any symbol or file. @@ -173,7 +228,7 @@ def preflight(target: str = "", staged: bool = False, root: str = ".") -> dict: return _run_roam(args, root) -@_tool() +@_tool(name="roam_search_symbol") def search_symbol(query: str, root: str = ".") -> dict: """Find symbols by name (case-insensitive substring match). @@ -194,7 +249,7 @@ def search_symbol(query: str, root: str = ".") -> dict: return _run_roam(["search", query], root) -@_tool() +@_tool(name="roam_context") def context(symbol: str, task: str = "", root: str = ".") -> dict: """Get the minimal context needed to work with a specific symbol. @@ -223,7 +278,7 @@ def context(symbol: str, task: str = "", root: str = ".") -> dict: return _run_roam(args, root) -@_tool() +@_tool(name="roam_trace") def trace(source: str, target: str, root: str = ".") -> dict: """Find the shortest dependency path between two symbols. @@ -245,7 +300,7 @@ def trace(source: str, target: str, root: str = ".") -> dict: return _run_roam(["trace", source, target], root) -@_tool() +@_tool(name="roam_impact") def impact(symbol: str, root: str = ".") -> dict: """Show the blast radius of changing a symbol. @@ -265,7 +320,7 @@ def impact(symbol: str, root: str = ".") -> dict: return _run_roam(["impact", symbol], root) -@_tool() +@_tool(name="roam_file_info") def file_info(path: str, root: str = ".") -> dict: """Show a file skeleton: every symbol definition with its signature. @@ -290,7 +345,7 @@ def file_info(path: str, root: str = ".") -> dict: # =================================================================== -@_tool() +@_tool(name="roam_pr_risk") def pr_risk(staged: bool = False, root: str = ".") -> dict: """Compute a risk score (0-100) for pending changes. @@ -313,7 +368,7 @@ def pr_risk(staged: bool = False, root: str = ".") -> dict: return _run_roam(args, root) -@_tool() +@_tool(name="roam_breaking_changes") def breaking_changes(target: str = "HEAD~1", root: str = ".") -> dict: """Detect breaking API changes between git refs. @@ -333,7 +388,7 @@ def breaking_changes(target: str = "HEAD~1", root: str = ".") -> dict: return _run_roam(["breaking", target], root) -@_tool() +@_tool(name="roam_affected_tests") def affected_tests(target: str = "", staged: bool = False, root: str = ".") -> dict: """Find test files that exercise the changed code. @@ -360,7 +415,7 @@ def affected_tests(target: str = "", staged: bool = False, root: str = ".") -> d return _run_roam(args, root) -@_tool() +@_tool(name="roam_algo") def algo(task: str = "", confidence: str = "", root: str = ".") -> dict: """Detect suboptimal algorithms and suggest better approaches. @@ -388,7 +443,7 @@ def algo(task: str = "", confidence: str = "", root: str = ".") -> dict: return _run_roam(args, root) -@_tool() +@_tool(name="roam_dark_matter") def dark_matter(min_npmi: float = 0.3, min_cochanges: int = 3, root: str = ".") -> dict: """Detect dark matter: file pairs that co-change but have no structural link. @@ -414,7 +469,7 @@ def dark_matter(min_npmi: float = 0.3, min_cochanges: int = 3, root: str = ".") return _run_roam(args, root) -@_tool() +@_tool(name="roam_dead_code") def dead_code(root: str = ".") -> dict: """List unreferenced exported symbols (dead code candidates). @@ -428,7 +483,7 @@ def dead_code(root: str = ".") -> dict: return _run_roam(["dead"], root) -@_tool() +@_tool(name="roam_complexity_report") def complexity_report(threshold: int = 15, root: str = ".") -> dict: """Rank functions by cognitive complexity. @@ -448,7 +503,7 @@ def complexity_report(threshold: int = 15, root: str = ".") -> dict: return _run_roam(["complexity", "--threshold", str(threshold)], root) -@_tool() +@_tool(name="roam_repo_map") def repo_map(budget: int = 0, root: str = ".") -> dict: """Show a compact project skeleton with key symbols. @@ -471,7 +526,7 @@ def repo_map(budget: int = 0, root: str = ".") -> dict: return _run_roam(args, root) -@_tool() +@_tool(name="roam_tour") def tour(root: str = ".") -> dict: """Generate a codebase onboarding guide. @@ -490,7 +545,7 @@ def tour(root: str = ".") -> dict: return _run_roam(["tour"], root) -@_tool() +@_tool(name="roam_visualize") def visualize( focus: str = "", format: str = "mermaid", @@ -540,7 +595,7 @@ def visualize( return _run_roam(args, root) -@_tool() +@_tool(name="roam_diagnose") def diagnose(symbol: str, depth: int = 2, root: str = ".") -> dict: """Root cause analysis for a failing symbol. @@ -565,7 +620,7 @@ def diagnose(symbol: str, depth: int = 2, root: str = ".") -> dict: return _run_roam(args, root) -@_tool() +@_tool(name="roam_relate") def relate(symbols: list[str], files: list[str] | None = None, depth: int = 3, root: str = ".") -> dict: """Show how a set of symbols relate: shared deps, call chains, conflicts. @@ -602,7 +657,7 @@ def relate(symbols: list[str], files: list[str] | None = None, # =================================================================== -@_tool() +@_tool(name="roam_annotate_symbol") def annotate_symbol( target: str, content: str, tag: str = "", author: str = "", expires: str = "", @@ -640,7 +695,7 @@ def annotate_symbol( return _run_roam(args, root) -@_tool() +@_tool(name="roam_get_annotations") def get_annotations( target: str = "", tag: str = "", since: str = "", root: str = ".", @@ -676,27 +731,27 @@ def get_annotations( # MCP Resources -- static/cached summaries available at fixed URIs # =================================================================== +if mcp is not None: -@mcp.resource("roam://health") -def get_health_resource() -> str: - """Current codebase health snapshot (JSON). - - Provides the same data as the ``health`` tool but exposed as an - MCP resource so agents can subscribe to or poll it. - """ - data = _run_roam(["health"]) - return json.dumps(data, indent=2) + @mcp.resource("roam://health") + def get_health_resource() -> str: + """Current codebase health snapshot (JSON). + Provides the same data as the ``health`` tool but exposed as an + MCP resource so agents can subscribe to or poll it. + """ + data = _run_roam(["health"]) + return json.dumps(data, indent=2) -@mcp.resource("roam://summary") -def get_summary_resource() -> str: - """Full codebase summary (JSON). + @mcp.resource("roam://summary") + def get_summary_resource() -> str: + """Full codebase summary (JSON). - Equivalent to calling the ``understand`` tool, exposed as a - resource for agents that prefer resource-based access. - """ - data = _run_roam(["understand"]) - return json.dumps(data, indent=2) + Equivalent to calling the ``understand`` tool, exposed as a + resource for agents that prefer resource-based access. + """ + data = _run_roam(["understand"]) + return json.dumps(data, indent=2) # =================================================================== @@ -704,7 +759,7 @@ def get_summary_resource() -> str: # =================================================================== -@_tool() +@_tool(name="roam_ws_understand") def ws_understand(root: str = ".") -> dict: """Get a unified overview of a multi-repo workspace. @@ -724,7 +779,7 @@ def ws_understand(root: str = ".") -> dict: return _run_roam(["ws", "understand"], root) -@_tool() +@_tool(name="roam_ws_context") def ws_context(symbol: str, root: str = ".") -> dict: """Get cross-repo augmented context for a symbol. @@ -744,7 +799,7 @@ def ws_context(symbol: str, root: str = ".") -> dict: return _run_roam(["ws", "context", symbol], root) -@_tool() +@_tool(name="roam_pr_diff") def pr_diff(staged: bool = False, commit_range: str = "", root: str = ".") -> dict: """Show structural consequences of code changes (graph delta). @@ -771,7 +826,7 @@ def pr_diff(staged: bool = False, commit_range: str = "", root: str = ".") -> di return _run_roam(args, root) -@_tool() +@_tool(name="roam_effects") def effects(target: str = "", file: str = "", effect_type: str = "", root: str = ".") -> dict: """Show side effects of functions (DB writes, network, filesystem, etc.). @@ -802,7 +857,7 @@ def effects(target: str = "", file: str = "", effect_type: str = "", root: str = return _run_roam(args, root) -@_tool() +@_tool(name="roam_budget_check") def budget_check(config: str = "", staged: bool = False, commit_range: str = "", root: str = ".") -> dict: """Check pending changes against architectural budgets. @@ -833,7 +888,7 @@ def budget_check(config: str = "", staged: bool = False, commit_range: str = "", return _run_roam(args, root) -@_tool() +@_tool(name="roam_attest") def attest(commit_range: str = "", staged: bool = False, output_format: str = "json", sign: bool = False, root: str = ".") -> dict: """Generate a proof-carrying PR attestation with all evidence bundled. @@ -868,7 +923,7 @@ def attest(commit_range: str = "", staged: bool = False, output_format: str = "j return _run_roam(args, root) -@_tool() +@_tool(name="roam_capsule_export") def capsule_export(redact_paths: bool = False, no_signatures: bool = False, root: str = ".") -> dict: """Export a sanitized structural graph without function bodies. @@ -894,7 +949,7 @@ def capsule_export(redact_paths: bool = False, no_signatures: bool = False, root return _run_roam(args, root) -@_tool() +@_tool(name="roam_path_coverage") def path_coverage(from_pattern: str = "", to_pattern: str = "", max_depth: int = 8, root: str = ".") -> dict: """Find critical call paths with zero test protection. @@ -925,7 +980,7 @@ def path_coverage(from_pattern: str = "", to_pattern: str = "", return _run_roam(args, root) -@_tool() +@_tool(name="roam_forecast") def forecast(symbol: str = "", horizon: int = 30, alert_only: bool = False, root: str = ".") -> dict: """Predict when metrics will exceed thresholds. @@ -956,7 +1011,7 @@ def forecast(symbol: str = "", horizon: int = 30, return _run_roam(args, root) -@_tool() +@_tool(name="roam_generate_plan") def generate_plan(target: str = "", task: str = "refactor", file_path: str = "", staged: bool = False, depth: int = 2, root: str = ".") -> dict: @@ -996,7 +1051,7 @@ def generate_plan(target: str = "", task: str = "refactor", return _run_roam(args, root) -@_tool() +@_tool(name="roam_adversarial_review") def adversarial_review(staged: bool = False, commit_range: str = "", severity: str = "low", root: str = ".") -> dict: """Adversarial architecture review — challenge code changes. @@ -1027,7 +1082,7 @@ def adversarial_review(staged: bool = False, commit_range: str = "", return _run_roam(args, root) -@_tool() +@_tool(name="roam_cut_analysis") def cut_analysis(between_a: str = "", between_b: str = "", leak_edges: bool = False, top_n: int = 10, root: str = ".") -> dict: @@ -1061,7 +1116,7 @@ def cut_analysis(between_a: str = "", between_b: str = "", return _run_roam(args, root) -@_tool() +@_tool(name="roam_get_invariants") def get_invariants(target: str = "", public_api: bool = False, breaking_risk: bool = False, top_n: int = 20, root: str = ".") -> dict: @@ -1096,7 +1151,7 @@ def get_invariants(target: str = "", public_api: bool = False, return _run_roam(args, root) -@_tool() +@_tool(name="roam_bisect_blame") def bisect_blame(metric: str = "health_score", threshold: float = 0, direction: str = "degraded", top_n: int = 10, root: str = ".") -> dict: @@ -1130,7 +1185,7 @@ def bisect_blame(metric: str = "health_score", threshold: float = 0, return _run_roam(args, root) -@_tool() +@_tool(name="roam_simulate") def simulate(operation: str, symbol: str = "", target_file: str = "", file_a: str = "", file_b: str = "", root: str = ".") -> dict: """Simulate a structural change and predict metric deltas. @@ -1173,7 +1228,7 @@ def simulate(operation: str, symbol: str = "", target_file: str = "", return _run_roam(args, root) -@_tool() +@_tool(name="roam_closure") def closure(symbol: str, rename: str = "", delete: bool = False, root: str = ".") -> dict: """Compute the minimal set of changes needed when modifying a symbol. @@ -1203,7 +1258,7 @@ def closure(symbol: str, rename: str = "", delete: bool = False, root: str = "." return _run_roam(args, root) -@_tool() +@_tool(name="roam_doc_intent") def doc_intent(symbol: str = "", doc: str = "", drift: bool = False, undocumented: bool = False, top_n: int = 20, root: str = ".") -> dict: @@ -1243,7 +1298,7 @@ def doc_intent(symbol: str = "", doc: str = "", return _run_roam(args, root) -@_tool() +@_tool(name="roam_fingerprint") def fingerprint(compact: bool = False, export_path: str = "", compare_path: str = "", root: str = ".") -> dict: """Extract a topology fingerprint for cross-repo comparison. @@ -1276,7 +1331,7 @@ def fingerprint(compact: bool = False, export_path: str = "", return _run_roam(args, root) -@_tool() +@_tool(name="roam_rules_check") def rules_check(ci: bool = False, rules_dir: str = "", root: str = ".") -> dict: """Evaluate custom governance rules defined in .roam/rules/. @@ -1303,7 +1358,7 @@ def rules_check(ci: bool = False, rules_dir: str = "", root: str = ".") -> dict: return _run_roam(args, root) -@_tool() +@_tool(name="roam_orchestrate") def orchestrate(n_agents: int, files: list[str] | None = None, staged: bool = False, root: str = ".") -> dict: """Partition codebase for parallel multi-agent work (swarm orchestration). @@ -1334,7 +1389,7 @@ def orchestrate(n_agents: int, files: list[str] | None = None, return _run_roam(args, root) -@_tool() +@_tool(name="roam_mutate") def mutate(operation: str, symbol: str = "", target_file: str = "", new_name: str = "", from_symbol: str = "", to_symbol: str = "", args: str = "", lines: str = "", apply: bool = False, @@ -1400,7 +1455,7 @@ def mutate(operation: str, symbol: str = "", target_file: str = "", return _run_roam(cmd_args, root) -@_tool() +@_tool(name="roam_vuln_map") def vuln_map(npm_audit: str = "", pip_audit: str = "", trivy: str = "", osv: str = "", generic: str = "", root: str = ".") -> dict: """Ingest vulnerability scanner reports and match to codebase symbols. @@ -1440,7 +1495,7 @@ def vuln_map(npm_audit: str = "", pip_audit: str = "", trivy: str = "", return _run_roam(args, root) -@_tool() +@_tool(name="roam_vuln_reach") def vuln_reach(from_entry: str = "", cve: str = "", root: str = ".") -> dict: """Query reachability of ingested vulnerabilities through the call graph. @@ -1472,7 +1527,7 @@ def vuln_reach(from_entry: str = "", cve: str = "", root: str = ".") -> dict: # =================================================================== -@_tool() +@_tool(name="roam_ingest_trace") def ingest_trace(trace_file: str, format: str = "", root: str = ".") -> dict: """Ingest runtime traces and match spans to symbols. @@ -1500,7 +1555,7 @@ def ingest_trace(trace_file: str, format: str = "", root: str = ".") -> dict: return _run_roam(args, root) -@_tool() +@_tool(name="roam_runtime_hotspots") def runtime_hotspots(runtime_sort: bool = False, discrepancy: bool = False, root: str = ".") -> dict: """Show runtime hotspots where static and runtime rankings disagree. @@ -1532,7 +1587,7 @@ def runtime_hotspots(runtime_sort: bool = False, discrepancy: bool = False, # =================================================================== -@_tool() +@_tool(name="roam_search_semantic") def search_semantic(query: str, top: int = 10, threshold: float = 0.05, root: str = ".") -> dict: """Find symbols by natural language query using TF-IDF semantic search. @@ -1560,9 +1615,453 @@ def search_semantic(query: str, top: int = 10, threshold: float = 0.05, return _run_roam(args, root) +# =================================================================== +# Daily workflow tools +# =================================================================== + + +@_tool(name="roam_diff") +def roam_diff(commit_range: str = "", staged: bool = False, root: str = ".") -> dict: + """Blast radius of uncommitted or committed changes. + + WHEN TO USE: call after making code changes to see what's affected + BEFORE committing. Shows affected symbols, files, tests, coupling + warnings, and fitness violations. + + WHEN NOT TO USE: for pre-PR analysis use roam_pr_risk instead. + + Parameters + ---------- + commit_range: + Git range like ``HEAD~3..HEAD`` or ``main..feature``. + Empty = uncommitted working tree changes. + staged: + If True, analyze git-staged changes only. + root: + Working directory (project root). + + Returns: changed files, affected symbols, blast radius metrics, + per-file breakdown. + """ + args = ["diff"] + if commit_range: + args.append(commit_range) + if staged: + args.append("--staged") + return _run_roam(args, root) + + +@_tool(name="roam_symbol") +def roam_symbol(name: str, full: bool = False, root: str = ".") -> dict: + """Symbol definition, callers, callees, and graph metrics. + + WHEN TO USE: when you need detailed info about a specific symbol -- + definition, who calls it, what it calls, PageRank, fan-in/out. + More focused than roam_context (which adds files-to-read). + + Parameters + ---------- + name: + Symbol name. Supports ``file:symbol`` for disambiguation. + full: + Show all callers/callees without truncation. + root: + Working directory (project root). + + Returns: name, kind, signature, location, docstring, PageRank, + in_degree, out_degree, callers list, callees list. + """ + args = ["symbol", name] + if full: + args.append("--full") + return _run_roam(args, root) + + +@_tool(name="roam_deps") +def roam_deps(path: str, full: bool = False, root: str = ".") -> dict: + """File-level import/imported-by relationships. + + WHEN TO USE: to understand a file's dependencies -- what it imports + and what imports it. File-level granularity. Use for module boundary + analysis and refactoring impact. + + Parameters + ---------- + path: + File path relative to project root. + full: + Show all dependencies without truncation. + root: + Working directory (project root). + + Returns: file path, imports list (paths, symbol counts), importers + list (files that import this one). + """ + args = ["deps", path] + if full: + args.append("--full") + return _run_roam(args, root) + + +@_tool(name="roam_uses") +def roam_uses(name: str, full: bool = False, root: str = ".") -> dict: + """All consumers of a symbol: callers, importers, inheritors. + + WHEN TO USE: to find ALL places using a symbol, grouped by edge type + (calls, imports, inheritance, trait usage). Broader than roam_impact. + Use for planning API changes. + + Parameters + ---------- + name: + Symbol name. Supports partial matching. + full: + Show all consumers without truncation. + root: + Working directory (project root). + + Returns: symbol name, total_consumers, total_files, consumers + grouped by edge kind with name, kind, and location. + """ + args = ["uses", name] + if full: + args.append("--full") + return _run_roam(args, root) + + +# =================================================================== +# Health tools +# =================================================================== + + +@_tool(name="roam_weather") +def roam_weather(count: int = 20, root: str = ".") -> dict: + """Code hotspots: churn x complexity ranking. + + WHEN TO USE: to find highest-leverage refactoring targets -- files + that are both complex AND frequently changed. Complements roam_health + with temporal data. + + Parameters + ---------- + count: + Number of hotspots to return (default 20). + root: + Working directory (project root). + + Returns: hotspots list with score, churn, complexity, commit count, + author count, reason classification, and file path. + """ + args = ["weather", "-n", str(count)] + return _run_roam(args, root) + + +@_tool(name="roam_debt") +def roam_debt(limit: int = 20, by_kind: bool = False, threshold: float = 0.0, + root: str = ".") -> dict: + """Hotspot-weighted technical debt prioritization with remediation costs. + + WHEN TO USE: to get a prioritized refactoring list. Combines health + signals (complexity, cycles, god components, dead exports) with churn. + Includes SQALE remediation cost estimates in dev-minutes. + + Parameters + ---------- + limit: + Number of files to return (default 20). + by_kind: + Group results by parent directory. + threshold: + Only show files with debt score >= this value. + root: + Working directory (project root). + + Returns: summary (total_files, total_debt, remediation time), + suggestions, items list with debt_score, health_penalty, hotspot_factor, + breakdown, commit_count, distinct_authors. + """ + args = ["debt", "-n", str(limit)] + if by_kind: + args.append("--by-kind") + if threshold > 0: + args.extend(["--threshold", str(threshold)]) + return _run_roam(args, root) + + +# =================================================================== +# Backend analysis -- framework-specific issue detection +# =================================================================== + + +@_tool(name="roam_n1") +def roam_n1(confidence: str = "medium", verbose: bool = False, root: str = ".") -> dict: + """Detect implicit N+1 I/O patterns in ORM code. + + WHEN TO USE: to find hidden N+1 query problems -- computed properties + on data classes that trigger lazy-loaded queries during serialization. + Supports Laravel/Eloquent, Django, Rails, SQLAlchemy, JPA/Hibernate. + + Parameters + ---------- + confidence: + Filter: "low", "medium", "high" (default medium). + verbose: + Include call chain traces from property to I/O. + root: + Working directory (project root). + + Returns: findings with model, property, I/O operation, collection + contexts, eager-loading status, severity, suggested fix. + """ + args = ["n1"] + if confidence != "medium": + args.extend(["--confidence", confidence]) + if verbose: + args.append("--verbose") + return _run_roam(args, root) + + +@_tool(name="roam_auth_gaps") +def roam_auth_gaps(routes_only: bool = False, controllers_only: bool = False, + min_confidence: str = "medium", root: str = ".") -> dict: + """Find endpoints missing authentication or authorization. + + WHEN TO USE: security audit -- detects routes outside auth middleware, + controllers without authorize() checks. Supports Laravel, Django, + Rails, Express. + + Parameters + ---------- + routes_only: + Only check route definitions. + controllers_only: + Only check controller authorization. + min_confidence: + Minimum: "low", "medium", "high" (default medium). + root: + Working directory (project root). + + Returns: findings with endpoint, location, missing protection type, + severity (CRITICAL/HIGH/MEDIUM), suggested fix. Summary by severity. + """ + args = ["auth-gaps"] + if routes_only: + args.append("--routes-only") + if controllers_only: + args.append("--controllers-only") + if min_confidence != "medium": + args.extend(["--min-confidence", min_confidence]) + return _run_roam(args, root) + + +@_tool(name="roam_over_fetch") +def roam_over_fetch(threshold: int = 10, confidence: str = "medium", + root: str = ".") -> dict: + """Detect models serializing too many fields (data over-exposure). + + WHEN TO USE: to find API responses leaking too many fields. Detects + large $fillable without $hidden, direct controller returns bypassing + Resources, poor exposed-to-hidden ratio. + + Parameters + ---------- + threshold: + Minimum exposed field count to flag (default 10). + confidence: + Filter: "low", "medium", "high" (default medium). + root: + Working directory (project root). + + Returns: findings with model, exposed/hidden counts, ratio, + serialization method, severity, suggested fix. + """ + args = ["over-fetch", "--threshold", str(threshold)] + if confidence != "medium": + args.extend(["--confidence", confidence]) + return _run_roam(args, root) + + +@_tool(name="roam_missing_index") +def roam_missing_index(table: str = "", confidence: str = "medium", + root: str = ".") -> dict: + """Find queries on non-indexed columns (potential slow queries). + + WHEN TO USE: to detect queries that will table-scan instead of using + indexes. Cross-references WHERE/ORDER BY/foreign keys against + migration-defined indexes. Supports Laravel, Django, Rails, Alembic. + + Parameters + ---------- + table: + Only check queries against this table. + confidence: + Filter: "low", "medium", "high" (default medium). + root: + Working directory (project root). + + Returns: findings with table, column, query type, existing indexes, + severity, suggested index DDL. + """ + args = ["missing-index"] + if table: + args.extend(["--table", table]) + if confidence != "medium": + args.extend(["--confidence", confidence]) + return _run_roam(args, root) + + +@_tool(name="roam_orphan_routes") +def roam_orphan_routes(limit: int = 50, confidence: str = "medium", + root: str = ".") -> dict: + """Detect backend routes with no frontend consumer (dead endpoints). + + WHEN TO USE: to find API endpoints defined but never called. Parses + route files, searches frontend for API call references. + + Parameters + ---------- + limit: + Maximum findings (default 50). + confidence: + Filter: "low", "medium", "high" (default medium). + root: + Working directory (project root). + + Returns: findings with route path, HTTP method, controller, location, + confidence, suggested action. Summary with safe removal candidates. + """ + args = ["orphan-routes", "-n", str(limit)] + if confidence != "medium": + args.extend(["--confidence", confidence]) + return _run_roam(args, root) + + +@_tool(name="roam_migration_safety") +def roam_migration_safety(limit: int = 50, include_archive: bool = False, + root: str = ".") -> dict: + """Detect non-idempotent database migrations (unsafe for re-run). + + WHEN TO USE: to find migrations that will fail or corrupt data if run + twice. Detects missing hasTable/hasColumn guards, raw SQL without + IF NOT EXISTS. + + Parameters + ---------- + limit: + Maximum findings (default 50). + include_archive: + Check old migrations too (>6 months). + root: + Working directory (project root). + + Returns: findings with migration file, operation type, missing guard, + severity, risk explanation, suggested fix with guard code. + """ + args = ["migration-safety", "-n", str(limit)] + if include_archive: + args.append("--include-archive") + return _run_roam(args, root) + + +@_tool(name="roam_api_drift") +def roam_api_drift(model: str = "", confidence: str = "medium", + root: str = ".") -> dict: + """Detect mismatches between backend models and frontend interfaces. + + WHEN TO USE: to find drift between PHP $fillable/$appends and + TypeScript interface properties. Detects missing fields, extra fields, + type mismatches. Auto-converts snake_case/camelCase. + + Parameters + ---------- + model: + Only check this model. Empty = check all. + confidence: + Filter: "low", "medium", "high" (default medium). + root: + Working directory (project root). + + Returns: findings with model, interface, drift type, field, + backend/frontend types, severity, suggested fix. + """ + args = ["api-drift"] + if model: + args.extend(["--model", model]) + if confidence != "medium": + args.extend(["--confidence", confidence]) + return _run_roam(args, root) + + +# --------------------------------------------------------------------------- +# CLI command +# --------------------------------------------------------------------------- + + +@click.command() +@click.option('--transport', type=click.Choice(['stdio', 'sse']), default='stdio', + help='transport protocol (default: stdio)') +@click.option('--host', default='localhost', help='host for SSE mode') +@click.option('--port', type=int, default=8000, help='port for SSE mode') +@click.option('--no-auto-index', is_flag=True, help='skip automatic index freshness check') +@click.option('--list-tools', is_flag=True, help='list registered tools and exit') +def mcp_cmd(transport, host, port, no_auto_index, list_tools): + """Start the roam MCP server. + + \b + usage: + roam mcp # stdio (for Claude Code, Cursor, etc.) + roam mcp --transport sse # SSE on localhost:8000 + roam mcp --list-tools # show registered tools + + \b + environment: + ROAM_MCP_LITE=0 # expose all tools (default: lite/core only) + + \b + integration: + claude mcp add roam-code -- roam mcp + + \b + requires: + pip install roam-code[mcp] + """ + if mcp is None: + click.echo( + "error: fastmcp is required for the MCP server.\n" + "install it with: pip install roam-code[mcp]", + err=True, + ) + raise SystemExit(1) + + if list_tools: + mode = "lite" if _LITE else "full" + click.echo(f"{len(_REGISTERED_TOOLS)} tools registered ({mode} mode):\n") + for t in sorted(_REGISTERED_TOOLS): + click.echo(f" {t}") + return + + if not no_auto_index: + sys.stderr.write("checking index freshness...\n") + err = _ensure_fresh_index(".") + if err: + sys.stderr.write(f"warning: {err['error']}\n") + else: + sys.stderr.write("index is fresh.\n") + + if transport == "stdio": + mcp.run() + else: + mcp.run(transport="sse", host=host, port=port) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- if __name__ == "__main__": + if mcp is None: + raise SystemExit( + "fastmcp is required for the MCP server.\n" + "Install it with: pip install roam-code[mcp]" + ) mcp.run() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 00000000..941544c1 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,395 @@ +"""Tests for the MCP server module. + +Tests cover: +- _classify_error() error pattern matching +- _ensure_fresh_index() with mocked subprocess +- _run_roam() structured error responses +- _tool() decorator lite-mode filtering +- mcp_cmd CLI command +- Tool wrapper argument construction +""" + +from __future__ import annotations + +import json +import os +from unittest.mock import patch, MagicMock + +import pytest +from click.testing import CliRunner + + +# --------------------------------------------------------------------------- +# _classify_error tests +# --------------------------------------------------------------------------- + + +class TestClassifyError: + """Test error classification returns correct codes and hints.""" + + def _classify(self, stderr, exit_code=1): + from roam.mcp_server import _classify_error + return _classify_error(stderr, exit_code) + + def test_index_not_found_no_roam(self): + code, hint = self._classify("Error: No .roam directory found") + assert code == "INDEX_NOT_FOUND" + assert "roam init" in hint + + def test_index_not_found_in_index(self): + code, hint = self._classify("symbol 'foo' not found in index") + assert code == "INDEX_NOT_FOUND" + + def test_index_not_found_db(self): + code, hint = self._classify("cannot open index.db") + assert code == "INDEX_NOT_FOUND" + + def test_index_stale(self): + code, hint = self._classify("warning: index is stale, run roam index") + assert code == "INDEX_STALE" + assert "roam index" in hint + + def test_not_git_repo(self): + code, hint = self._classify("fatal: not a git repository") + assert code == "NOT_GIT_REPO" + assert "git init" in hint + + def test_db_locked(self): + code, hint = self._classify("sqlite3.OperationalError: database is locked") + assert code == "DB_LOCKED" + + def test_permission_denied(self): + code, hint = self._classify("OSError: Permission denied: '/foo/bar'") + assert code == "PERMISSION_DENIED" + + def test_no_results_symbol(self): + code, hint = self._classify("symbol not found: 'bazqux'") + assert code == "NO_RESULTS" + assert "search term" in hint + + def test_no_matches(self): + code, hint = self._classify("no matches for pattern 'xyz'") + assert code == "NO_RESULTS" + + def test_generic_failure(self): + code, hint = self._classify("something went wrong", exit_code=1) + assert code == "COMMAND_FAILED" + + def test_unknown_success(self): + code, hint = self._classify("", exit_code=0) + assert code == "UNKNOWN" + + def test_patterns_ordered_specific_first(self): + # "not found in index" should match INDEX_NOT_FOUND, not a more generic pattern + code, _ = self._classify("Error: symbol 'x' not found in index database") + assert code == "INDEX_NOT_FOUND" + + def test_permission_on_index_gets_permission_denied(self): + # "permission denied" is more specific than generic index errors + code, _ = self._classify("index.db: Permission denied") + assert code == "PERMISSION_DENIED" + + def test_case_insensitive(self): + code, _ = self._classify("PERMISSION DENIED for path /etc/shadow") + assert code == "PERMISSION_DENIED" + + +# --------------------------------------------------------------------------- +# _ensure_fresh_index tests +# --------------------------------------------------------------------------- + + +class TestEnsureFreshIndex: + """Test index freshness checking.""" + + def test_success(self): + from roam.mcp_server import _ensure_fresh_index + with patch("roam.mcp_server._run_roam") as mock: + mock.return_value = {"summary": {"files": 10}} + result = _ensure_fresh_index(".") + assert result is None + mock.assert_called_once_with(["index"], ".") + + def test_failure(self): + from roam.mcp_server import _ensure_fresh_index + with patch("roam.mcp_server._run_roam") as mock: + mock.return_value = {"error": "permission denied"} + result = _ensure_fresh_index(".") + assert result is not None + assert "error" in result + assert "permission denied" in result["error"] + + +# --------------------------------------------------------------------------- +# _run_roam tests +# --------------------------------------------------------------------------- + + +class TestRunRoam: + """Test the roam CLI subprocess wrapper.""" + + def test_success(self): + from roam.mcp_server import _run_roam + payload = {"summary": {"health_score": 85}} + with patch("subprocess.run") as mock: + mock.return_value = MagicMock( + returncode=0, + stdout=json.dumps(payload), + stderr="", + ) + result = _run_roam(["health"], ".") + assert result == payload + + def test_failure_with_structured_error(self): + from roam.mcp_server import _run_roam + with patch("subprocess.run") as mock: + mock.return_value = MagicMock( + returncode=1, + stdout="", + stderr="Error: No .roam directory found", + ) + result = _run_roam(["health"], ".") + assert "error" in result + assert result["error_code"] == "INDEX_NOT_FOUND" + assert "hint" in result + assert result["exit_code"] == 1 + assert "command" in result + + def test_timeout(self): + from roam.mcp_server import _run_roam + import subprocess + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="roam", timeout=60)): + result = _run_roam(["health"], ".") + assert "error" in result + assert "timed out" in result["error"] + + def test_json_decode_error(self): + from roam.mcp_server import _run_roam + with patch("subprocess.run") as mock: + mock.return_value = MagicMock( + returncode=0, + stdout="not json {{{", + stderr="", + ) + result = _run_roam(["health"], ".") + assert "error" in result + assert "JSON" in result["error"] + + +# --------------------------------------------------------------------------- +# _tool decorator tests +# --------------------------------------------------------------------------- + + +class TestToolDecorator: + """Test the MCP tool registration decorator.""" + + def test_lite_mode_filters_non_core(self): + """Non-core tools should be plain functions in lite mode.""" + import roam.mcp_server as mod + # In lite mode (default), non-core tool functions are not registered + # They should still be callable as regular functions + assert callable(mod.visualize) + + def test_core_tools_set_has_expected_members(self): + """Core tools set should contain the documented tools.""" + from roam.mcp_server import _CORE_TOOLS + expected = { + "roam_understand", "roam_search_symbol", "roam_context", + "roam_file_info", "roam_deps", "roam_preflight", "roam_diff", + "roam_pr_risk", "roam_affected_tests", "roam_impact", + "roam_uses", "roam_health", "roam_dead_code", + "roam_complexity_report", "roam_diagnose", "roam_trace", + } + assert _CORE_TOOLS == expected + + def test_core_tools_count(self): + from roam.mcp_server import _CORE_TOOLS + assert len(_CORE_TOOLS) == 16 + + +# --------------------------------------------------------------------------- +# mcp_cmd CLI tests +# --------------------------------------------------------------------------- + + +class TestMcpCmd: + """Test the roam mcp CLI command.""" + + def test_help(self): + from roam.mcp_server import mcp_cmd + runner = CliRunner() + result = runner.invoke(mcp_cmd, ["--help"]) + assert result.exit_code == 0 + assert "roam mcp" in result.output + assert "--transport" in result.output + assert "--no-auto-index" in result.output + + def test_missing_fastmcp(self): + """When fastmcp isn't installed, should fail with clear message.""" + from roam.mcp_server import mcp_cmd + runner = CliRunner() + with patch("roam.mcp_server.mcp", None): + result = runner.invoke(mcp_cmd, ["--no-auto-index"]) + assert result.exit_code == 1 + assert "roam-code[mcp]" in result.output + + def test_list_tools_flag(self): + """--list-tools should print registered tools without starting server.""" + from roam.mcp_server import mcp_cmd + runner = CliRunner() + # Even without fastmcp, --list-tools should fail gracefully + # (it checks mcp is None first) + with patch("roam.mcp_server.mcp", None): + result = runner.invoke(mcp_cmd, ["--list-tools"]) + assert result.exit_code == 1 # mcp is None check fires first + + +# --------------------------------------------------------------------------- +# Tool wrapper argument construction tests +# --------------------------------------------------------------------------- + + +class TestToolWrappers: + """Test that tool wrappers construct correct CLI arguments.""" + + def _check_args(self, fn, kwargs, expected_args): + """Call a tool function with mocked _run_roam and verify args.""" + with patch("roam.mcp_server._run_roam") as mock: + mock.return_value = {"ok": True} + fn(**kwargs) + mock.assert_called_once() + actual_args = mock.call_args[0][0] + assert actual_args == expected_args + + def test_roam_diff_default(self): + from roam.mcp_server import roam_diff + self._check_args(roam_diff, {}, ["diff"]) + + def test_roam_diff_with_range(self): + from roam.mcp_server import roam_diff + self._check_args( + roam_diff, + {"commit_range": "HEAD~3..HEAD", "staged": False}, + ["diff", "HEAD~3..HEAD"], + ) + + def test_roam_diff_staged(self): + from roam.mcp_server import roam_diff + self._check_args(roam_diff, {"staged": True}, ["diff", "--staged"]) + + def test_roam_symbol(self): + from roam.mcp_server import roam_symbol + self._check_args(roam_symbol, {"name": "foo"}, ["symbol", "foo"]) + + def test_roam_symbol_full(self): + from roam.mcp_server import roam_symbol + self._check_args( + roam_symbol, {"name": "foo", "full": True}, + ["symbol", "foo", "--full"], + ) + + def test_roam_deps(self): + from roam.mcp_server import roam_deps + self._check_args(roam_deps, {"path": "src/cli.py"}, ["deps", "src/cli.py"]) + + def test_roam_uses(self): + from roam.mcp_server import roam_uses + self._check_args(roam_uses, {"name": "open_db"}, ["uses", "open_db"]) + + def test_roam_weather(self): + from roam.mcp_server import roam_weather + self._check_args(roam_weather, {"count": 10}, ["weather", "-n", "10"]) + + def test_roam_debt(self): + from roam.mcp_server import roam_debt + self._check_args(roam_debt, {}, ["debt", "-n", "20"]) + + def test_roam_debt_full(self): + from roam.mcp_server import roam_debt + self._check_args( + roam_debt, + {"limit": 5, "by_kind": True, "threshold": 10.0}, + ["debt", "-n", "5", "--by-kind", "--threshold", "10.0"], + ) + + def test_roam_n1(self): + from roam.mcp_server import roam_n1 + self._check_args(roam_n1, {}, ["n1"]) + + def test_roam_n1_with_options(self): + from roam.mcp_server import roam_n1 + self._check_args( + roam_n1, + {"confidence": "high", "verbose": True}, + ["n1", "--confidence", "high", "--verbose"], + ) + + def test_roam_auth_gaps(self): + from roam.mcp_server import roam_auth_gaps + self._check_args(roam_auth_gaps, {}, ["auth-gaps"]) + + def test_roam_auth_gaps_routes_only(self): + from roam.mcp_server import roam_auth_gaps + self._check_args( + roam_auth_gaps, + {"routes_only": True}, + ["auth-gaps", "--routes-only"], + ) + + def test_roam_over_fetch(self): + from roam.mcp_server import roam_over_fetch + self._check_args(roam_over_fetch, {}, ["over-fetch", "--threshold", "10"]) + + def test_roam_missing_index(self): + from roam.mcp_server import roam_missing_index + self._check_args(roam_missing_index, {}, ["missing-index"]) + + def test_roam_orphan_routes(self): + from roam.mcp_server import roam_orphan_routes + self._check_args(roam_orphan_routes, {}, ["orphan-routes", "-n", "50"]) + + def test_roam_migration_safety(self): + from roam.mcp_server import roam_migration_safety + self._check_args(roam_migration_safety, {}, ["migration-safety", "-n", "50"]) + + def test_roam_api_drift(self): + from roam.mcp_server import roam_api_drift + self._check_args(roam_api_drift, {}, ["api-drift"]) + + def test_roam_api_drift_with_model(self): + from roam.mcp_server import roam_api_drift + self._check_args( + roam_api_drift, + {"model": "User", "confidence": "high"}, + ["api-drift", "--model", "User", "--confidence", "high"], + ) + + +# --------------------------------------------------------------------------- +# Error pattern table tests +# --------------------------------------------------------------------------- + + +class TestErrorPatterns: + """Test the _ERROR_PATTERNS table structure.""" + + def test_patterns_are_lowercase(self): + from roam.mcp_server import _ERROR_PATTERNS + for pattern, code, hint in _ERROR_PATTERNS: + assert pattern == pattern.lower(), f"pattern '{pattern}' should be lowercase" + + def test_codes_are_uppercase(self): + from roam.mcp_server import _ERROR_PATTERNS + for pattern, code, hint in _ERROR_PATTERNS: + assert code == code.upper(), f"code '{code}' should be uppercase" + + def test_hints_end_with_period(self): + from roam.mcp_server import _ERROR_PATTERNS + for pattern, code, hint in _ERROR_PATTERNS: + assert hint.endswith("."), f"hint for {code} should end with period" + + def test_no_duplicate_patterns(self): + from roam.mcp_server import _ERROR_PATTERNS + patterns = [p for p, _, _ in _ERROR_PATTERNS] + assert len(patterns) == len(set(patterns)), "duplicate patterns found"