Skip to content

Commit 331f76d

Browse files
CosmoHacclaude
andcommitted
fix: polish MCP server — missing deps, error handling, tests
Address review issues on PR #10: - Add fastmcp>=2.0 as optional dependency (pip install roam-code[mcp]) - Move imports to top-level (click, sys) instead of mid-file/in-function - Make _tool() decorator require name param, add _REGISTERED_TOOLS tracking - Replace broad _classify_error if/elif chain with data-driven _ERROR_PATTERNS table, fix pattern ordering so permission errors aren't misclassified - Simplify _ensure_fresh_index (both branches did the same call) - Guard __main__ block for mcp=None - Add --list-tools flag to roam mcp command - Update install messages to reference pip install roam-code[mcp] - Add 50 tests: error classification, subprocess mocking, CLI runner, tool arg construction, pattern table validation - Update CLAUDE.md, README, llms-install with new counts and MCP docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 20b9c31 commit 331f76d

6 files changed

Lines changed: 470 additions & 49 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
roam-code is a CLI tool that gives AI coding agents instant codebase comprehension.
66
It pre-indexes symbols, call graphs, dependencies, architecture, and git history into
7-
a local SQLite DB. 94 commands, 26 languages, 100% local, zero API keys.
7+
a local SQLite DB. 95 commands, 26 languages, 100% local, zero API keys.
88

99
**Package:** `roam-code` on PyPI. Entry point: `roam.cli:cli`.
1010

@@ -38,7 +38,7 @@ roam health
3838
```
3939
src/roam/
4040
cli.py # Click CLI entry point — LazyGroup, _COMMANDS dict, _CATEGORIES
41-
mcp_server.py # FastMCP server (48 tools, 2 resources)
41+
mcp_server.py # FastMCP server (61 tools, 2 resources) + `roam mcp` CLI command
4242
__init__.py # Version string (reads from pyproject.toml via importlib.metadata)
4343
db/
4444
schema.py # SQLite schema (CREATE TABLE statements)
@@ -101,12 +101,12 @@ src/roam/
101101
gate_presets.py # Framework-specific gate rules + .roam-gates.yml loader
102102
graph_helpers.py # Shared graph utilities (adjacency builders, BFS helpers)
103103
context_helpers.py # Data-gathering helpers extracted from cmd_context.py
104-
cmd_*.py # One module per CLI command (93 modules, 94 commands)
104+
cmd_*.py # One module per CLI command (93 modules, 95 commands)
105105
output/
106106
formatter.py # Token-efficient text formatting, abbrev_kind(), loc(), format_table(), to_json(), json_envelope()
107107
sarif.py # SARIF 2.1.0 output (--sarif flag on health/debt/complexity)
108108
schema_registry.py # JSON envelope schema versioning + validation
109-
tests/ # 70 test files
109+
tests/ # 71 test files
110110
# Core & legacy
111111
test_basic.py, test_comprehensive.py, test_fixes.py, test_performance.py,
112112
test_resolve.py, test_salesforce.py, test_v6_features.py,
@@ -130,7 +130,8 @@ tests/ # 70 test files
130130
test_capsule.py, test_forecast.py, test_path_coverage.py,
131131
test_minimap.py, test_attest.py, test_annotations.py, test_budget.py,
132132
test_pr_diff.py, test_framework_detection.py, test_backend_fixes_round2.py,
133-
test_backend_fixes_round3.py, test_exclude_patterns.py, test_math_tips.py
133+
test_backend_fixes_round3.py, test_exclude_patterns.py, test_math_tips.py,
134+
test_mcp_server.py
134135
```
135136

136137
### Key patterns
@@ -223,7 +224,7 @@ tests/ # 70 test files
223224
- tree-sitter >= 0.23 (AST parsing)
224225
- tree-sitter-language-pack >= 0.6 (165+ grammars)
225226
- networkx >= 3.0 (graph algorithms)
226-
- Optional: fastmcp (MCP server)
227+
- Optional: fastmcp >= 2.0 (MCP server`pip install roam-code[mcp]`)
227228
- Dev: pytest >= 7.0, pytest-xdist >= 3.0, ruff >= 0.4
228229

229230
## Version bumping
@@ -249,5 +250,5 @@ Additional commands: `roam health` (0-100 score), `roam impact <name>` (what bre
249250
`roam simulate move <sym> <file>` (what-if architecture), `roam orchestrate` (multi-agent partitioning),
250251
`roam adversarial` (attack surface review), `roam mutate move <sym> <file>` (code transforms).
251252

252-
Run `roam --help` for all 94 commands. Use `roam --json <cmd>` for structured output.
253+
Run `roam --help` for all 95 commands. Use `roam --json <cmd>` for structured output.
253254
Use `roam --sarif health` for CI integration (SARIF 2.1.0).

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@ Run `roam --help` for all commands. Use `roam --json <cmd>` for structured outpu
706706
Roam includes a [Model Context Protocol](https://modelcontextprotocol.io/) server for direct integration with tools that support MCP.
707707

708708
```bash
709+
pip install roam-code[mcp]
709710
roam mcp
710711
```
711712

@@ -1326,7 +1327,7 @@ roam-code/
13261327
| [tree-sitter-language-pack](https://github.com/nicolo-ribaudo/tree-sitter-language-pack) >= 0.6 | 165+ grammars |
13271328
| [networkx](https://networkx.org/) >= 3.0 | Graph algorithms |
13281329

1329-
Optional: [fastmcp](https://github.com/jlowin/fastmcp) (MCP server dependency)
1330+
Optional: [fastmcp](https://github.com/jlowin/fastmcp) >= 2.0 (MCP server — install with `pip install roam-code[mcp]`)
13301331

13311332
## Roadmap
13321333

llms-install.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# Installing roam-code
22

33
roam-code provides instant codebase comprehension for AI coding agents.
4-
94 commands, 26 languages, 100% local, zero API keys.
4+
95 commands, 26 languages, 100% local, zero API keys.
55

66
## Quick install
77

88
```bash
99
pip install roam-code
10+
pip install roam-code[mcp] # optional: MCP server support
1011
```
1112

1213
Or with isolated environments:
@@ -71,4 +72,4 @@ Add to your MCP config:
7172
| `roam context <symbol>` | Files and line ranges to read |
7273
| `roam diff` | Blast radius of uncommitted changes |
7374

74-
Run `roam --help` for all 94 commands.
75+
Run `roam --help` for all 95 commands.

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ license = "MIT"
1212
authors = [
1313
{name = "CosmoHac"},
1414
]
15-
keywords = ["codebase", "code-analysis", "tree-sitter", "ai-tools", "cli", "static-analysis"]
15+
keywords = ["codebase", "code-analysis", "tree-sitter", "ai-tools", "cli", "static-analysis", "architecture", "mcp", "code-intelligence"]
1616
classifiers = [
1717
"Development Status :: 4 - Beta",
1818
"Environment :: Console",
@@ -44,6 +44,9 @@ Issues = "https://github.com/Cranot/roam-code/issues"
4444
roam = "roam.cli:cli"
4545

4646
[project.optional-dependencies]
47+
mcp = [
48+
"fastmcp>=2.0",
49+
]
4750
dev = [
4851
"pytest>=7.0",
4952
"pytest-xdist>=3.0",

src/roam/mcp_server.py

Lines changed: 58 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,21 @@
1414
import json
1515
import os
1616
import subprocess
17+
import sys
18+
19+
import click
1720

1821
try:
1922
from fastmcp import FastMCP
2023
except ImportError:
2124
FastMCP = None
2225

2326
# ---------------------------------------------------------------------------
24-
# Lite mode: expose only ~15 core tools when ROAM_MCP_LITE=1
27+
# Lite mode (default): expose only core tools for better agent tool selection.
28+
# Set ROAM_MCP_LITE=0 to expose all tools.
2529
# ---------------------------------------------------------------------------
2630

27-
_LITE = os.environ.get("ROAM_MCP_LITE", "1").lower() in ("1", "true", "yes")
31+
_LITE = os.environ.get("ROAM_MCP_LITE", "1").lower() not in ("0", "false", "no")
2832

2933
_CORE_TOOLS = {
3034
# comprehension (5)
@@ -55,17 +59,18 @@
5559
mcp = None
5660

5761

58-
def _tool(name=None):
59-
"""MCP tool decorator. In lite mode (ROAM_MCP_LITE=1), only core tools register."""
62+
_REGISTERED_TOOLS: list[str] = []
63+
64+
65+
def _tool(name: str):
66+
"""Register an MCP tool. In lite mode, only _CORE_TOOLS are registered."""
6067
def decorator(fn):
6168
if mcp is None:
6269
return fn
63-
tool_name = name or fn.__name__
64-
if _LITE and tool_name not in _CORE_TOOLS:
70+
if _LITE and name not in _CORE_TOOLS:
6571
return fn
66-
if name:
67-
return mcp.tool(name=name)(fn)
68-
return mcp.tool()(fn)
72+
_REGISTERED_TOOLS.append(name)
73+
return mcp.tool(name=name)(fn)
6974
return decorator
7075

7176

@@ -74,35 +79,36 @@ def decorator(fn):
7479
# ---------------------------------------------------------------------------
7580

7681

82+
_ERROR_PATTERNS: list[tuple[str, str, str]] = [
83+
# (pattern, error_code, hint) — checked in order, first match wins.
84+
# More specific patterns MUST come before broader ones.
85+
("no .roam", "INDEX_NOT_FOUND", "run `roam init` to create the codebase index."),
86+
("not found in index", "INDEX_NOT_FOUND", "run `roam init` to create the codebase index."),
87+
("index is stale", "INDEX_STALE", "run `roam index` to refresh."),
88+
("out of date", "INDEX_STALE", "run `roam index` to refresh."),
89+
("not a git repository","NOT_GIT_REPO", "some commands require git history. run: git init."),
90+
("database is locked", "DB_LOCKED", "another roam process is running. wait or delete .roam/index.lock."),
91+
("permission denied", "PERMISSION_DENIED","check file permissions."),
92+
("cannot open index", "INDEX_NOT_FOUND", "run `roam init` to create the codebase index."),
93+
("symbol not found", "NO_RESULTS", "try a different search term or check spelling."),
94+
("no matches", "NO_RESULTS", "try a different search term or check spelling."),
95+
("no results", "NO_RESULTS", "try a different search term or check spelling."),
96+
]
97+
98+
7799
def _classify_error(stderr: str, exit_code: int) -> tuple[str, str]:
78-
"""classify error and return (error_code, hint)."""
100+
"""Classify error and return (error_code, hint)."""
79101
s = stderr.lower()
80-
if "not found in index" in s or "no .roam" in s or "index.db" in s:
81-
return ("INDEX_NOT_FOUND", "run `roam init` to create the codebase index.")
82-
if "stale" in s or "out of date" in s:
83-
return ("INDEX_STALE", "run `roam index` to refresh.")
84-
if "not found" in s or "no matches" in s or "no results" in s:
85-
return ("NO_RESULTS", "try a different search term or check spelling.")
86-
if "not a git repository" in s:
87-
return ("NOT_GIT_REPO", "some commands require git history. run: git init")
88-
if "permission denied" in s:
89-
return ("PERMISSION_DENIED", "check file permissions.")
90-
if "database" in s and "locked" in s:
91-
return ("DB_LOCKED", "another roam process is running. wait or delete .roam/index.lock")
92-
if exit_code == 1:
102+
for pattern, code, hint in _ERROR_PATTERNS:
103+
if pattern in s:
104+
return (code, hint)
105+
if exit_code != 0:
93106
return ("COMMAND_FAILED", "check arguments and try again.")
94107
return ("UNKNOWN", "check the error message for details.")
95108

96109

97110
def _ensure_fresh_index(root: str = ".") -> dict | None:
98-
"""check index freshness, rebuild if stale. returns None if ok."""
99-
from pathlib import Path
100-
index_path = Path(root).resolve() / ".roam" / "index.db"
101-
if not index_path.exists():
102-
result = _run_roam(["index"], root)
103-
if "error" in result:
104-
return {"error": f"failed to create index: {result['error']}"}
105-
return None
111+
"""Run incremental index to ensure freshness. Returns None on success."""
106112
result = _run_roam(["index"], root)
107113
if "error" in result:
108114
return {"error": f"index update failed: {result['error']}"}
@@ -1990,41 +1996,50 @@ def roam_api_drift(model: str = "", confidence: str = "medium",
19901996
# CLI command
19911997
# ---------------------------------------------------------------------------
19921998

1993-
import click
1994-
19951999

19962000
@click.command()
19972001
@click.option('--transport', type=click.Choice(['stdio', 'sse']), default='stdio',
19982002
help='transport protocol (default: stdio)')
19992003
@click.option('--host', default='localhost', help='host for SSE mode')
20002004
@click.option('--port', type=int, default=8000, help='port for SSE mode')
20012005
@click.option('--no-auto-index', is_flag=True, help='skip automatic index freshness check')
2002-
def mcp_cmd(transport, host, port, no_auto_index):
2006+
@click.option('--list-tools', is_flag=True, help='list registered tools and exit')
2007+
def mcp_cmd(transport, host, port, no_auto_index, list_tools):
20032008
"""Start the roam MCP server.
20042009
20052010
\b
20062011
usage:
20072012
roam mcp # stdio (for Claude Code, Cursor, etc.)
20082013
roam mcp --transport sse # SSE on localhost:8000
2014+
roam mcp --list-tools # show registered tools
20092015
20102016
\b
20112017
environment:
2012-
ROAM_MCP_LITE=1 # expose only core tools
2018+
ROAM_MCP_LITE=0 # expose all tools (default: lite/core only)
20132019
20142020
\b
20152021
integration:
20162022
claude mcp add roam-code -- roam mcp
2017-
"""
2018-
import sys
20192023
2024+
\b
2025+
requires:
2026+
pip install roam-code[mcp]
2027+
"""
20202028
if mcp is None:
20212029
click.echo(
20222030
"error: fastmcp is required for the MCP server.\n"
2023-
"install it with: pip install fastmcp",
2031+
"install it with: pip install roam-code[mcp]",
20242032
err=True,
20252033
)
20262034
raise SystemExit(1)
20272035

2036+
if list_tools:
2037+
mode = "lite" if _LITE else "full"
2038+
click.echo(f"{len(_REGISTERED_TOOLS)} tools registered ({mode} mode):\n")
2039+
for t in sorted(_REGISTERED_TOOLS):
2040+
click.echo(f" {t}")
2041+
return
2042+
20282043
if not no_auto_index:
20292044
sys.stderr.write("checking index freshness...\n")
20302045
err = _ensure_fresh_index(".")
@@ -2044,4 +2059,9 @@ def mcp_cmd(transport, host, port, no_auto_index):
20442059
# ---------------------------------------------------------------------------
20452060

20462061
if __name__ == "__main__":
2062+
if mcp is None:
2063+
raise SystemExit(
2064+
"fastmcp is required for the MCP server.\n"
2065+
"Install it with: pip install roam-code[mcp]"
2066+
)
20472067
mcp.run()

0 commit comments

Comments
 (0)