diff --git a/README.md b/README.md index 1c2ea6f..e54e085 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,47 @@ See [plugin/README.md](plugin/README.md) for detailed documentation. --- +## MCP server (experimental) + +Bring CodeMap into any **Model Context Protocol**–capable client (Cursor, Claude Desktop, …) without editor-specific plugins. + +Install the optional extra: + +```bash +pip install "codemap[mcp] @ git+https://github.com/AZidan/codemap.git" +``` + +Run stdio transport (clients spawn this process): + +```bash +codemap-mcp +``` + +- Set **`CODEMAP_WORKSPACE_ROOT`** when the MCP host does not start `codemap-mcp` with cwd at your repo root. +- Server **`instructions`** ask assistants to poll `codemap_after_git_checkpoint` each user turn (honoring them is client-dependent). +- External git hooks can drop **`.git/CURSOR_SUGGEST_CODEMAP_REFRESH`** after merge/rebase/checkout—`codemap_after_git_checkpoint` surfaces it (same pattern as optional Cursor/git-hook workflows). + +### Testing MCP (any client) + +From a project that already has **`codemap init`** (or an existing `.codemap/`): + +1. Point the client at **`codemap-mcp`** (stdio) and ensure **`PATH`** includes the `codemap` binary; set **`CODEMAP_WORKSPACE_ROOT`** if cwd ≠ repo root. +2. Invoke tools in order: **`codemap_health`** → **`codemap_find`** → **`codemap_show`** on a path returned by `find`. Output should match **`codemap find` / `codemap show`** in a terminal from the **same** repo root. + +### “Plugin parity” in Cursor, Claude Desktop, etc. + +The **Claude Code plugin** *teaches* the agent to prefer **`codemap find` → scoped reads** (see [Claude Code Plugin](#-claude-code-plugin)). MCP alone only adds **tools** + **`instructions`**; clients do **not** have to obey those hints. + +| Client | How to get behavior closer to the official Claude Code plugin | +|--------|------------------------------------------------------------------| +| **Claude Code** | `claude plugin install codemap` and/or copy `.claude/skills/codemap` into the project | +| **Cursor** | Project rules: `.cursor/rules/*.mdc` with **`alwaysApply: true`** (or `globs`) to prefer **`codemap_find` → `codemap_show`** before large **`read_file`** | +| **Claude Desktop** | MCP server config + your own system / project instructions with the same workflow | + +**Example** (Cursor): add `.cursor/rules/codemap-navigation.mdc` with frontmatter `alwaysApply: true` and bullets: use **`codemap_find`** first, **`codemap_show`** for structure/line ranges, **`read_file`** only for missing lines or unindexed paths; offer **`codemap_validate` / `codemap_update`** after git churn. This is optional but matches how teams validate “CodeMap-first” traces in the agent UI. + +--- + ## Installation ### Claude Code (Recommended) @@ -369,8 +410,11 @@ pip install git+https://github.com/AZidan/codemap.git # With TypeScript/JavaScript support pip install "codemap[treesitter] @ git+https://github.com/AZidan/codemap.git" -# All languages + watch mode +# All languages + watch mode + MCP stdio server pip install "codemap[all] @ git+https://github.com/AZidan/codemap.git" + +# MCP stdio server only (adds model-context-protocol Python SDK) +pip install "codemap[mcp] @ git+https://github.com/AZidan/codemap.git" ``` ### uv Install @@ -657,7 +701,7 @@ codemap/ Contributions welcome! Areas where help is needed: - **New language parsers** — Ruby, PHP, Scala -- **MCP server mode** — For non-Claude tools +- **MCP stdio server** — Experimental: `pip install codemap[mcp]`, run `codemap-mcp` (README section above). Further integration for non-Claude tools welcome. - **Fuzzy symbol search** — `codemap find "usr srv"` → `UserService` - **VSCode extension** — GUI for non-CLI users diff --git a/codemap/mcp/__init__.py b/codemap/mcp/__init__.py new file mode 100644 index 0000000..46155eb --- /dev/null +++ b/codemap/mcp/__init__.py @@ -0,0 +1 @@ +"""Model Context Protocol (stdio) integration for CodeMap.""" diff --git a/codemap/mcp/server.py b/codemap/mcp/server.py new file mode 100644 index 0000000..91d1466 --- /dev/null +++ b/codemap/mcp/server.py @@ -0,0 +1,305 @@ +""" +CodeMap MCP server (stdio) — works with any MCP-capable client (Cursor, Claude Desktop, …). + +Depends on optional extra: pip install codemap[mcp] + +Resolves `codemap` via PATH or the interpreter's script directory (venv-friendly). +Optional env: CODEMAP_WORKSPACE_ROOT when cwd is ambiguous. + +Server instructions nudge assistants to poll `codemap_after_git_checkpoint`; MCP cannot +silently invoke tools — clients must honor `instructions`. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Annotated + +from mcp.server.fastmcp import FastMCP + +MARKER_NAME = "CURSOR_SUGGEST_CODEMAP_REFRESH" + + +def resolve_codemap() -> str | None: + exe = shutil.which("codemap") + if exe: + return exe + bundled = Path(sys.executable).resolve().parent / "codemap" + if bundled.is_file() and os.access(bundled, os.X_OK): + return str(bundled) + fb = Path.home() / ".local" / "bin" / "codemap" + if fb.is_file() and os.access(fb, os.X_OK): + return str(fb) + return None + + +def workspace_dir(explicit: str) -> str: + if explicit and explicit.strip(): + return os.path.realpath(os.path.expanduser(explicit.strip())) + env = os.environ.get("CODEMAP_WORKSPACE_ROOT", "").strip() + if env: + return os.path.realpath(os.path.expanduser(env)) + return os.path.realpath(os.getcwd()) + + +def git_absolute_dir(workspace_root: str) -> str | None: + r = subprocess.run( + ["git", "rev-parse", "--absolute-git-dir"], + cwd=workspace_root, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + if r.returncode != 0: + return None + p = (r.stdout or "").strip() + return p if os.path.isdir(p) else None + + +def peek_marker_snippet(workspace_root: str = "") -> str: + """One-line boot hint for server instructions (no mutation).""" + root = workspace_dir(workspace_root) + gd = git_absolute_dir(root) + if not gd: + return "" + marker = os.path.join(gd, MARKER_NAME) + if not os.path.isfile(marker): + return "" + try: + with open(marker, encoding="utf-8") as fh: + raw = fh.read().strip() + except OSError: + return "(pending marker unreadable)" + if "\t" in raw: + reason, stamp = raw.split("\t", 1) + else: + reason, stamp = raw, "" + bit = stamp.strip() or "" + suf = f" ({bit})" if bit else "" + return ( + f"PENDING GIT SYNC{suf}: `{reason.strip()}` — call codemap_after_git_checkpoint " + "then offer codemap_validate / codemap_update." + ) + + +def build_server_instructions() -> str: + base = """You are connected to the **CodeMap** MCP server. + +**Automation policy — apply unless the user opts out of CodeMap for this task:** +1. **Every user-visible turn**, before substantive code edits or searches, call `codemap_after_git_checkpoint` once (omit `workspace_root` if cwd / CODEMAP_WORKSPACE_ROOT is correct). +2. If it reports **pending git**, inform the user and **offer** optional `codemap_validate` then `codemap_update` (all_files=true). Run `codemap_update` only after they agree or they asked to refresh. +3. For exploration, prefer `codemap_find` then narrow reads over whole files. +4. You may read resource `codemap://git-sync-status` for the same checkpoint text (read-only). + +This keeps indexes aligned with the working tree after merges/rebases without silent heavy work.""" + boot = peek_marker_snippet("") + if boot: + return base + "\n\n**Current boot notice:** " + boot + return base + + +mcp = FastMCP("codemap", instructions=build_server_instructions()) + + +def run_codemap(cwd: str, args: list[str], timeout: int = 600) -> str: + exe = resolve_codemap() + if not exe: + return "error: codemap CLI not found. Install CodeMap in this environment (same venv as codemap-mcp)." + if not os.path.isdir(cwd): + return f"error: workspace directory does not exist: {cwd}" + try: + r = subprocess.run( + [exe, *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired: + return f"error: codemap subprocess timed out after {timeout}s" + except OSError as e: + return f"error: failed to run codemap: {e}" + out = (r.stdout or "").strip() + err = (r.stderr or "").strip() + bits: list[str] = [] + if out: + bits.append(out) + if err: + bits.append("[stderr]\n" + err) + if r.returncode != 0: + bits.append(f"(exit code {r.returncode})") + return "\n".join(bits) if bits else f"(exit code {r.returncode}, no output)" + + +def describe_git_checkpoint(workspace_root: str, consume_marker: bool) -> str: + root = workspace_dir(workspace_root) + gd = git_absolute_dir(root) + if not gd: + return f"no git repo at workspace: {root}" + marker = os.path.join(gd, MARKER_NAME) + if not os.path.isfile(marker): + return ( + "no pending git-sync hint for CodeMap.\n" + "(Marker absent — no recent merge/rebase/checkout ping, or already consumed.)" + ) + try: + with open(marker, encoding="utf-8") as fh: + raw = fh.read().strip() + except OSError as e: + return f"error reading marker: {e}" + if "\t" in raw: + reason, stamp = raw.split("\t", 1) + else: + reason, stamp = raw, "" + reason = reason.strip() + stamp = stamp.strip() + stamp_part = f" at {stamp}" if stamp else "" + body = ( + f"pending_git_sync_marker: yes\nreason: {reason}{stamp_part}\n\n" + "Optional — line ranges may be stale until refreshed:\n" + " 1) codemap_validate\n" + " 2) codemap_update(all_files=true) — only after user confirms or asks\n\n" + "Cheap reads depend on an up-to-date index (CodeMap philosophy)." + ) + if consume_marker: + try: + os.unlink(marker) + body += "\n\n(marker consumed / deleted)" + except OSError as e: + body += f"\n\n(warning: could not consume marker: {e})" + return body + + +@mcp.resource("codemap://git-sync-status") +def resource_git_sync_status() -> str: + """Same payload as `codemap_after_git_checkpoint` without consuming the marker.""" + return describe_git_checkpoint("", consume_marker=False) + + +@mcp.tool() +def codemap_after_git_checkpoint( + workspace_root: Annotated[str, "Repo root (empty => env/cwd)"] = "", + consume_marker: Annotated[ + bool, + "If true, delete the marker after returning (after successful codemap_update).", + ] = False, +) -> str: + """Optional `.git/` sentinel from external git hooks (Cursor / custom).""" + return describe_git_checkpoint(workspace_root, consume_marker) + + +@mcp.tool() +def codemap_health( + workspace_root: Annotated[str, "Repository root (empty => CODEMAP_WORKSPACE_ROOT or cwd)"] = "", +) -> str: + """Check codemap CLI and `.codemap/.codemap.json` presence.""" + root = workspace_dir(workspace_root) + exe = resolve_codemap() + exe_line = exe or "(not found)" + manifest = os.path.join(root, ".codemap", ".codemap.json") + mf = "present" if os.path.isfile(manifest) else "missing" + probe = "" + if exe: + r = subprocess.run( + [exe, "--help"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + probe = ( + (r.stdout or r.stderr or "").splitlines()[0] + if (r.stdout or r.stderr) + else f"exit {r.returncode}" + ) + return f"codemap: {exe_line}\nworkspace: {root}\nmanifest .codemap/.codemap.json: {mf}\n{probe}".strip() + + +@mcp.tool() +def codemap_find( + query: Annotated[str, "Symbol or substring to search for"], + workspace_root: Annotated[str, "Repo root (empty => env/cwd)"] = "", + symbol_type: Annotated[str, "Optional codemap --type filter"] = "", + fuzzy: Annotated[bool, "Use codemap --fuzzy"] = False, +) -> str: + """Run `codemap find`.""" + root = workspace_dir(workspace_root) + args: list[str] = ["find", query] + if symbol_type.strip(): + args.extend(["--type", symbol_type.strip()]) + if fuzzy: + args.append("--fuzzy") + return run_codemap(root, args) + + +@mcp.tool() +def codemap_show( + file_path: Annotated[str, "Path relative to workspace root"], + workspace_root: Annotated[str, "Repo root (empty => env/cwd)"] = "", +) -> str: + """Run `codemap show` for symbol ranges in one file.""" + root = workspace_dir(workspace_root) + return run_codemap(root, ["show", file_path]) + + +@mcp.tool() +def codemap_validate( + workspace_root: Annotated[str, "Repo root (empty => env/cwd)"] = "", + path: Annotated[str, "Optional file to validate"] = "", +) -> str: + """Run `codemap validate`.""" + root = workspace_dir(workspace_root) + args = ["validate"] + if path.strip(): + args.append(path.strip()) + return run_codemap(root, args) + + +@mcp.tool() +def codemap_update( + workspace_root: Annotated[str, "Repo root (empty => env/cwd)"] = "", + all_files: Annotated[bool, "Run codemap update --all"] = True, + single_path: Annotated[str, "When all_files false, path to refresh"] = "", +) -> str: + """Run `codemap update --all` or refresh one path.""" + root = workspace_dir(workspace_root) + if all_files: + return run_codemap(root, ["update", "--all"], timeout=1200) + if not single_path.strip(): + return "error: set single_path when all_files is false" + return run_codemap(root, ["update", single_path.strip()], timeout=1200) + + +@mcp.tool() +def codemap_init( + workspace_root: Annotated[str, "Repo root (empty => env/cwd)"] = "", + extra_args: Annotated[str, "Extra tokens passed after `codemap init .`"] = "", +) -> str: + """Run `codemap init .` plus optional whitespace-separated extra args.""" + root = workspace_dir(workspace_root) + args = ["init", "."] + if extra_args.strip(): + args.extend(extra_args.split()) + return run_codemap(root, args, timeout=1200) + + +@mcp.tool() +def codemap_stats( + workspace_root: Annotated[str, "Repo root (empty => env/cwd)"] = "", +) -> str: + """Run `codemap stats`.""" + root = workspace_dir(workspace_root) + return run_codemap(root, ["stats"]) + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index b461693..a7c2195 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,9 @@ languages = [ watch = [ "watchdog>=3.0", ] +mcp = [ + "mcp>=1.0,<2", +] dev = [ "pytest>=7.0", "pytest-cov", @@ -95,11 +98,12 @@ dev = [ "ruff", ] all = [ - "codemap[languages,watch,dev]", + "codemap[languages,watch,dev,mcp]", ] [project.scripts] codemap = "codemap.cli:cli" +codemap-mcp = "codemap.mcp.server:main" [build-system] requires = ["hatchling"]