diff --git a/AGENTS.md b/AGENTS.md index 1b2679b9..608680c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,3 +154,26 @@ cgraph info [--repo ] # Repo stats + metadata ``` `--repo` defaults to the current directory name. Claude Code skill in `skills/code-graph/`. + +## MCP server (for agents) + +`cgraph-mcp` exposes the code graph over MCP stdio. Eight tools: +`index_repo`, `search_code`, `get_callers`, `get_callees`, +`get_dependencies`, `impact_analysis`, `find_path`, `ask`. + +Drop the canonical agent guidance into any repo: + +```bash +cgraph init-agent # writes CLAUDE.md + .cursorrules +cgraph init-agent --force # overwrite existing files +``` + +See `api/mcp/templates/claude_mcp_section.md` for the full tool table +and rules of thumb (start with `search_code`; prefer structural tools +over `ask`; run `impact_analysis` before refactoring). + +Environment: + +- `CODE_GRAPH_AUTO_INDEX=true` — auto-index CWD on MCP startup. +- `CGRAPH_MODE=mcp` — run `cgraph-mcp` instead of the FastAPI web + server when using the Docker image. diff --git a/README.md b/README.md index e2e54bb0..417209ec 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,48 @@ npx skills add FalkorDB/code-graph Then ask Claude things like *"what functions call analyze_sources?"* or *"find the dependency chain between parse_config and send_request"* — it will handle the indexing and querying automatically. +### MCP server (`cgraph-mcp`) + +For agents that speak the [Model Context Protocol](https://modelcontextprotocol.io) +(Claude Code, Cursor, Cline, …), code-graph ships a stdio MCP server +that exposes the knowledge graph as 8 first-class tools: `index_repo`, +`search_code`, `get_callers`, `get_callees`, `get_dependencies`, +`impact_analysis`, `find_path`, and `ask` (NL→Cypher via GraphRAG). + +Quickstart — Claude Code: + +```bash +# 1. Install (in any venv with the cgraph package on PATH) +pip install code-graph # or: uv pip install code-graph + +# 2. Register with Claude Code +claude mcp add-json code-graph '{ + "command": "cgraph-mcp", + "env": { + "FALKORDB_HOST": "localhost", + "FALKORDB_PORT": "6379", + "CODE_GRAPH_AUTO_INDEX": "true" + } +}' + +# 3. Drop agent guidance into your repo +cd /path/to/your/repo +cgraph init-agent # writes CLAUDE.md and .cursorrules +``` + +Quickstart — Docker Compose: + +```bash +docker compose up -d falkordb # start the DB +docker compose --profile mcp run --rm -i code-graph-mcp # attach via stdio +``` + +The MCP server auto-bootstraps FalkorDB if it's missing on localhost +(via `cgraph ensure-db`). When `CODE_GRAPH_AUTO_INDEX=true` is set, +the current working directory is indexed automatically on start. + +**Transport:** Phase 1 is stdio only. HTTP/SSE is deferred. + ## Running with Docker ### Using Docker Compose @@ -232,11 +274,19 @@ docker compose up --build This starts FalkorDB and the CodeGraph app together. The checked-in compose file sets `CODE_GRAPH_PUBLIC=1` for the app service. +To run the **MCP stdio server** instead of the web app from the same +image, set `CGRAPH_MODE=mcp` and use the `mcp` profile: + +```bash +docker compose --profile mcp run --rm -i code-graph-mcp +``` + ### Using Docker directly ```bash docker build -t code-graph . +# Web mode (default) docker run -p 5000:5000 \ -e FALKORDB_HOST=host.docker.internal \ -e FALKORDB_PORT=6379 \ @@ -244,6 +294,14 @@ docker run -p 5000:5000 \ -e GEMINI_API_KEY= \ -e SECRET_TOKEN= \ code-graph + +# MCP stdio mode (same image) +docker run --rm -i \ + -e CGRAPH_MODE=mcp \ + -e FALKORDB_HOST=host.docker.internal \ + -e FALKORDB_PORT=6379 \ + -e MODEL_NAME=gemini/gemini-flash-lite-latest \ + code-graph ``` ## Creating a Code Graph diff --git a/api/cli.py b/api/cli.py index bf6d9d46..bf9ed4d6 100644 --- a/api/cli.py +++ b/api/cli.py @@ -413,5 +413,47 @@ def info( _json_out({"repo": name, "branch": branch, **stats, "metadata": metadata}) +# ── init-agent ───────────────────────────────────────────────────────── + + +_TEMPLATES_DIR = Path(__file__).parent / "mcp" / "templates" + + +@app.command("init-agent") +def init_agent( + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite existing CLAUDE.md / .cursorrules." + ), +) -> None: + """Drop AI-agent guidance files (CLAUDE.md, .cursorrules) into CWD. + + Copies the canonical code-graph MCP guidance bundled with this + package so any repo can announce the tools to Cursor and Claude + Code with one command. + """ + targets = { + "CLAUDE.md": _TEMPLATES_DIR / "claude_mcp_section.md", + ".cursorrules": _TEMPLATES_DIR / "cursorrules.template", + } + + cwd = Path.cwd() + if not force: + existing = [name for name in targets if (cwd / name).exists()] + if existing: + _json_error( + f"Refusing to overwrite existing files: {', '.join(existing)}. " + "Re-run with --force to clobber." + ) + + written: List[str] = [] + for name, template in targets.items(): + dest = cwd / name + dest.write_text(template.read_text(encoding="utf-8"), encoding="utf-8") + written.append(str(dest)) + _stderr(f"Wrote {dest}") + + _json_out({"status": "ok", "written": written, "force": force}) + + if __name__ == "__main__": app() diff --git a/api/mcp/templates/claude_mcp_section.md b/api/mcp/templates/claude_mcp_section.md new file mode 100644 index 00000000..adf8b009 --- /dev/null +++ b/api/mcp/templates/claude_mcp_section.md @@ -0,0 +1,42 @@ +# code-graph MCP server — agent guidance + +This repo is indexed into a FalkorDB **code knowledge graph** exposed +to you over MCP as `code-graph`. Use it instead of grepping when you +need to understand how symbols connect. + +## When to call each tool + +| Tool | Call this when… | Example | +|---|---|---| +| `index_repo(path_or_url, branch?)` | **First** thing in a new repo; or after large changes outside your edits. Project name is **derived from the folder or repo URL** — read it back from the response. | `index_repo(path_or_url=".")` | +| `search_code(prefix, project)` | You know part of a symbol name and need its id. | `search_code(prefix="processPay", project="myrepo")` | +| `get_callers(symbol_id, project)` | "Who calls this?" — refactoring a function, tracking down a regression. | `get_callers(symbol_id=42, project="myrepo")` | +| `get_callees(symbol_id, project)` | "What does this call?" — understanding a function before editing it. | `get_callees(symbol_id=42, project="myrepo")` | +| `get_dependencies(symbol_id, project)` | All edges out of a symbol (CALLS + IMPORTS + DEFINES). | `get_dependencies(symbol_id=42, project="myrepo")` | +| `impact_analysis(symbol_id, project, direction, depth)` | **"What breaks if I change this?"** Transitive upstream callers. | `impact_analysis(symbol_id=42, project="myrepo", direction="IN", depth=3)` | +| `find_path(source_id, dest_id, project)` | Show the call chain between two known symbols. | `find_path(source_id=10, dest_id=42, project="myrepo")` | +| `ask(question, project)` | Open-ended natural-language question. **More expensive — use last.** | `ask(question="why does login fail when MFA is on?", project="myrepo")` | + +## Rules of thumb + +1. **Start with `search_code`** to turn names into ids. Most tools take a `symbol_id`. +2. **Prefer structural tools over `ask`.** `get_callers` is one cheap Cypher + hop; `ask` is two LLM round-trips. Use `ask` for fuzzy/conceptual + questions, not for "who calls X". +3. **`impact_analysis` before refactoring.** Even when you think you know + the answer — the transitive closure often surprises you. +4. **`branch` is optional** but pass it when working on a feature branch + so you query the right per-branch index. +5. **Response shape.** Tools that return collections (`search_code`, + `get_callers`, `get_callees`, `get_dependencies`, `find_path`, + `impact_analysis`) put the array in `structuredContent.result` per + the MCP spec. The text content is the same JSON for convenience. + `index_repo` and `ask` return a single object. + +## Environment + +- `CODE_GRAPH_AUTO_INDEX=true` — auto-index CWD on first tool call (off by + default; opt-in because indexing big repos takes minutes). +- `FALKORDB_HOST` / `FALKORDB_PORT` — defaults to `localhost:6379`. If + unreachable on localhost, the server runs `cgraph ensure-db` to + spin up the official Docker image. diff --git a/api/mcp/templates/cursorrules.template b/api/mcp/templates/cursorrules.template new file mode 100644 index 00000000..aa449eaf --- /dev/null +++ b/api/mcp/templates/cursorrules.template @@ -0,0 +1,32 @@ +# Cursor rules — code-graph MCP + +This project is indexed into a FalkorDB code knowledge graph via the +`code-graph` MCP server. Use it instead of grepping when you need to +understand how symbols connect. + +## Tool selection + +- Symbol lookup by name: use `code-graph.search_code` (gives you the + numeric id every other tool needs). +- "Who calls X?": `code-graph.get_callers`. +- "What does X call?": `code-graph.get_callees`. +- All outgoing edges (CALLS + IMPORTS + DEFINES): `code-graph.get_dependencies`. +- Refactoring impact ("what breaks if I change X"): + `code-graph.impact_analysis` with `direction="IN"`. +- Call chain between two specific symbols: `code-graph.find_path`. +- Natural-language question over the graph (expensive — last resort): + `code-graph.ask`. + +## Rules + +- Always `search_code` first to resolve names to ids. +- Prefer structural tools (`get_callers`, `find_path`, `impact_analysis`) + over `ask` for "who/what/where" questions. +- Run `impact_analysis(direction="IN", depth=3)` before any non-trivial + refactor. +- Pass `branch` when on a feature branch. + +## First run + +If the repo isn't indexed yet, call `index_repo(path=".")` once. After +that, navigate via the structural tools. diff --git a/docker-compose.yml b/docker-compose.yml index e6de30d2..dd2e9f30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,4 +22,21 @@ services: - FALKORDB_PORT=6379 - OPENAI_API_KEY=${OPENAI_API_KEY:-} - SECRET_TOKEN=${SECRET_TOKEN:-} - - CODE_GRAPH_PUBLIC=1 \ No newline at end of file + - CODE_GRAPH_PUBLIC=1 + + # MCP stdio server — opt-in. Bring up with: + # docker compose run --rm -i code-graph-mcp + # then point Claude Code / Cursor at the running container's stdio. + code-graph-mcp: + build: . + depends_on: + - falkordb + profiles: ["mcp"] + stdin_open: true + tty: false + environment: + - CGRAPH_MODE=mcp + - FALKORDB_HOST=falkordb + - FALKORDB_PORT=6379 + - MODEL_NAME=${MODEL_NAME:-gemini/gemini-flash-lite-latest} + - CODE_GRAPH_AUTO_INDEX=${CODE_GRAPH_AUTO_INDEX:-} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ab0c176e..c18f5629 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["."] +[tool.setuptools.package-data] +"api.mcp" = ["templates/*"] + [dependency-groups] dev = [ "pytest>=9.0.2", diff --git a/scripts/mcp_smoke.py b/scripts/mcp_smoke.py new file mode 100644 index 00000000..38e25d44 --- /dev/null +++ b/scripts/mcp_smoke.py @@ -0,0 +1,180 @@ +"""End-to-end MCP smoke test. + +Spawns `cgraph-mcp` over stdio, lists tools, indexes the +code-graph repo itself, and exercises `search_code`, +`get_callers`, and `impact_analysis`. Prints a compact pass/fail line per +tool. +""" + +import asyncio +import json +import os +import sys +from pathlib import Path + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INDEX_PATH = REPO_ROOT / "api" +PROJECT_NAME = "code-graph-mcp-smoke" +BRANCH = "smoke" + + +def _pretty(result): + """Pull the first text payload out of a CallToolResult.""" + for chunk in result.content: + if hasattr(chunk, "text"): + try: + return json.loads(chunk.text) + except Exception: + return chunk.text + # No text chunks — show structured content if MCP put the payload there. + if hasattr(result, "structuredContent") and result.structuredContent is not None: + return result.structuredContent + return None + + +async def main() -> int: + env = { + **os.environ, + "FALKORDB_HOST": os.environ.get("FALKORDB_HOST", "127.0.0.1"), + "FALKORDB_PORT": os.environ.get("FALKORDB_PORT", "6390"), + } + + params = StdioServerParameters( + command="cgraph-mcp", + args=[], + env=env, + ) + + fails = 0 + + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + tool_names = sorted(t.name for t in tools.tools) + print(f"[tools] {len(tool_names)}: {tool_names}") + expected = { + "index_repo", + "search_code", + "get_callers", + "get_callees", + "get_dependencies", + "impact_analysis", + "find_path", + "ask", + } + missing = expected - set(tool_names) + if missing: + print(f"[FAIL] missing tools: {missing}") + fails += 1 + + print(f"[index_repo] indexing {INDEX_PATH} ...") + idx = await session.call_tool( + "index_repo", + { + "path_or_url": str(INDEX_PATH), + "branch": BRANCH, + "ignore": [".venv", "node_modules", ".git", "app/dist"], + }, + ) + idx_payload = _pretty(idx) + print(f"[index_repo] -> {json.dumps(idx_payload)[:200]}") + if not isinstance(idx_payload, dict) or idx_payload.get("error"): + print("[FAIL] index_repo did not return ok payload") + fails += 1 + return 1 + project_name = idx_payload["project_name"] + branch_name = idx_payload["branch"] + print(f"[index_repo] graph={idx_payload['graph_name']} project={project_name}") + + print("[search_code] prefix='index_repo'") + sr = await session.call_tool( + "search_code", + {"prefix": "index_repo", "project": project_name, "branch": branch_name}, + ) + sr_payload = _pretty(sr) + print(f"[search_code] -> {json.dumps(sr_payload)[:300]}") + if isinstance(sr_payload, list): + hits = sr_payload + elif isinstance(sr_payload, dict) and "results" in sr_payload: + hits = sr_payload["results"] + elif isinstance(sr_payload, dict) and "id" in sr_payload: + hits = [sr_payload] + else: + hits = [] + if not hits: + print("[FAIL] search_code returned no hits for index_repo") + fails += 1 + first_id = None + else: + first_id = hits[0].get("id") + print(f"[search_code] picked id={first_id} name={hits[0].get('name')}") + + if first_id is not None: + print(f"[get_callers] id={first_id}") + gc = await session.call_tool( + "get_callers", + { + "symbol_id": first_id, + "project": project_name, + "branch": branch_name, + }, + ) + gc_payload = _pretty(gc) + # Some MCP servers return list payloads in structuredContent only. + gc_struct = getattr(gc, "structuredContent", None) + print(f"[get_callers] -> {json.dumps(gc_payload)[:300]} struct={json.dumps(gc_struct)[:200]}") + # Acceptable shapes: list of caller dicts, or {"callers": [...]}. + callers = None + if isinstance(gc_payload, list): + callers = gc_payload + elif isinstance(gc_payload, dict) and "callers" in gc_payload: + callers = gc_payload["callers"] + elif isinstance(gc_struct, dict) and "result" in gc_struct: + callers = gc_struct["result"] + if callers is None: + print("[FAIL] get_callers returned no recognizable payload") + fails += 1 + else: + print(f"[get_callers] {len(callers)} callers") + + print("[impact_analysis] depth=2") + ia = await session.call_tool( + "impact_analysis", + { + "symbol_id": first_id, + "depth": 2, + "project": project_name, + "branch": branch_name, + }, + ) + ia_payload = _pretty(ia) + ia_struct = getattr(ia, "structuredContent", None) + print(f"[impact_analysis] -> {json.dumps(ia_payload)[:300]} struct={json.dumps(ia_struct)[:200]}") + impacted = None + if isinstance(ia_payload, dict) and "impacted" in ia_payload: + impacted = ia_payload["impacted"] + elif isinstance(ia_struct, dict) and "impacted" in ia_struct: + impacted = ia_struct["impacted"] + elif isinstance(ia_struct, dict) and "result" in ia_struct: + impacted = ia_struct["result"] + if impacted is None: + print("[FAIL] impact_analysis no 'impacted' field") + fails += 1 + else: + print(f"[impact_analysis] {len(impacted)} impacted") + + if fails: + print(f"\n=== {fails} FAILED ===") + return 1 + print("\n=== ALL OK ===") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/start.sh b/start.sh index b01ffed7..05394b8c 100755 --- a/start.sh +++ b/start.sh @@ -4,6 +4,7 @@ set -e # Set default values if not set FALKORDB_HOST="${FALKORDB_HOST:-localhost}" FALKORDB_PORT="${FALKORDB_PORT:-6379}" +CGRAPH_MODE="${CGRAPH_MODE:-web}" # Start FalkorDB Redis server in background only if using a local address (not an external instance) if [ "${FALKORDB_HOST}" = "localhost" ] || [[ "${FALKORDB_HOST}" =~ ^127\.0\.0\.[0-9]+$ ]]; then @@ -12,7 +13,7 @@ fi # Wait until FalkorDB is ready FALKORDB_WAIT_TIMEOUT="${FALKORDB_WAIT_TIMEOUT:-30}" -echo "Waiting for FalkorDB to start on $FALKORDB_HOST:$FALKORDB_PORT (timeout: ${FALKORDB_WAIT_TIMEOUT}s)..." +echo "Waiting for FalkorDB to start on $FALKORDB_HOST:$FALKORDB_PORT (timeout: ${FALKORDB_WAIT_TIMEOUT}s)..." >&2 SECONDS=0 while ! nc -z "$FALKORDB_HOST" "$FALKORDB_PORT"; do @@ -23,7 +24,16 @@ while ! nc -z "$FALKORDB_HOST" "$FALKORDB_PORT"; do sleep 0.5 done -echo "FalkorDB is up - launching server..." +echo "FalkorDB is up — launching ${CGRAPH_MODE} mode..." >&2 -# Start the backend -exec uvicorn api.index:app --host "${HOST:-0.0.0.0}" --port "${PORT:-5000}" ${APP_RELOAD:+--reload} +# Dispatch on CGRAPH_MODE. Default ("web") preserves the original +# behaviour. "mcp" runs the stdio MCP server so the same image can be +# attached to Claude Code / Cursor without rebuilding. +case "${CGRAPH_MODE}" in + mcp) + exec cgraph-mcp + ;; + web|*) + exec uvicorn api.index:app --host "${HOST:-0.0.0.0}" --port "${PORT:-5000}" ${APP_RELOAD:+--reload} + ;; +esac diff --git a/tests/mcp/test_init_agent.py b/tests/mcp/test_init_agent.py new file mode 100644 index 00000000..4a14ba71 --- /dev/null +++ b/tests/mcp/test_init_agent.py @@ -0,0 +1,72 @@ +"""Tests for `cgraph init-agent` (T13). + +Verifies the CLI drops the MCP guidance templates into CWD and +respects `--force`. +""" + +import os + +import pytest +from typer.testing import CliRunner + +from api.cli import app + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _cd(monkeypatch: pytest.MonkeyPatch, path) -> None: + monkeypatch.chdir(path) + + +def test_init_agent_writes_both_files(runner, tmp_path, monkeypatch): + _cd(monkeypatch, tmp_path) + + result = runner.invoke(app, ["init-agent"]) + + assert result.exit_code == 0, result.output + claude = tmp_path / "CLAUDE.md" + cursor = tmp_path / ".cursorrules" + assert claude.exists() + assert cursor.exists() + # Templates should mention at least one core MCP tool. + body = claude.read_text() + cursor.read_text() + assert "search_code" in body + assert "code-graph" in body.lower() + + +def test_init_agent_refuses_overwrite_without_force(runner, tmp_path, monkeypatch): + _cd(monkeypatch, tmp_path) + (tmp_path / "CLAUDE.md").write_text("preexisting\n") + + result = runner.invoke(app, ["init-agent"]) + + assert result.exit_code != 0 + # Existing file untouched. + assert (tmp_path / "CLAUDE.md").read_text() == "preexisting\n" + # And we didn't write the other one either. + assert not (tmp_path / ".cursorrules").exists() + + +def test_init_agent_force_overwrites(runner, tmp_path, monkeypatch): + _cd(monkeypatch, tmp_path) + (tmp_path / "CLAUDE.md").write_text("preexisting\n") + (tmp_path / ".cursorrules").write_text("old rules\n") + + result = runner.invoke(app, ["init-agent", "--force"]) + + assert result.exit_code == 0, result.output + assert "preexisting" not in (tmp_path / "CLAUDE.md").read_text() + assert "old rules" not in (tmp_path / ".cursorrules").read_text() + assert "search_code" in (tmp_path / "CLAUDE.md").read_text() + + +def test_init_agent_templates_ship_with_package(): + """Smoke check: the bundled template files exist on disk where the + CLI expects them. Guards against pyproject.toml drift.""" + from api.cli import _TEMPLATES_DIR + + assert (_TEMPLATES_DIR / "claude_mcp_section.md").is_file() + assert (_TEMPLATES_DIR / "cursorrules.template").is_file()