Skip to main content
Image de couverture pour Cartog: exposing a code graph to an LLM agent via Model Context Protocol

Cartog: exposing a code graph to an LLM agent via Model Context Protocol

TL;DR
A code graph is only useful to an agent if it knows how to query it. Cartog exposes 16 tools via Model Context Protocol (stdio JSON-RPC) (e.g. cartog_search, cartog_outline, cartog_refs, cartog_callees, etc.).
Each tool’s description lets the agent pick the right one based on context (“use when… not for…”).
SQLite runs in WAL mode to allow concurrent reads and writes for better performance.
To try it out and learn more: MCP documentation · Cartog source code.

The four previous articles build a directed graph: symbols extracted by tree-sitter, semantic embeddings for meaning-based search, edges resolved more precisely with LSP, relevant re-indexing thanks to the Merkle tree.

From the CLI, it works: cartog impact validate --depth 3 responds in a few milliseconds.

But in practice, the user doesn’t type cartog impact themselves.
They chat with an agent (Claude Code, Cursor, etc.) that has to decide whether to query the graph, how, and in what order.

The user asks “What’s the impact of renaming authenticate?”. How can the agent make the right decision and call Cartog?

Two questions to solve on the server side:

  1. Transport: how to expose these functions from our Rust CLI to an LLM process running elsewhere — without custom HTTP and without per-editor integration.
  2. LLM readability: how to describe each tool so the agent picks the right one without hardcoding any routing logic.

The Model Context Protocol is an open specification published by Anthropic in late 2024, then adopted by other agent editors.

The idea: a standard for plugging external tools into an agent (just as LSP standardizes plugging language servers into an IDE).

In one sentence (this isn’t meant to be a full tutorial)
An MCP server communicates via JSON-RPC over stdio, the client launches the binary and exchanges in two phases: a negotiation (the client asks for the list of tools and their schemas) and calls (the agent invokes a tool and receives a structured result).

sequenceDiagram
    participant A as Client / Agent
    participant S as MCP Server

    Note over A,S: 1. Negotiation (once, at startup)
    A->>S: initialize
    S-->>A: capabilities
    A->>S: tools/list
    S-->>A: [ {name, description, inputSchema}, ... ]

    Note over A,S: 2. Calls (each agent turn)
    A->>S: tools/call {name, arguments}
    S-->>A: result (structured JSON)

For a French-language introduction to the protocol itself (beyond Cartog), see the talks by Bertrand Nau and Horacio Gonzalez.

Cartog implements the MCP protocol and any MCP client benefits from it, without dedicated per-editor integration.

The Cartog docs currently list twelve compatible clients (Claude Code, Cursor, VS Code, Claude Desktop, Codex CLI, Gemini CLI, OpenCode, Windsurf, Zed, Antigravity, Kiro, Hermes Agent). The list grows with the protocol and new editors — nothing needs to change on Cartog’s side.

Applied to Cartog, this generic schema gives a concrete flow:

  • the agent translates a user question into a tools/call
  • the server queries SQLite and returns JSON.
sequenceDiagram
    participant U as User
    participant A as Agent (e.g. Claude Code)
    participant M as cartog serve (MCP)
    participant DB as SQLite (.cartog/)

    U->>A: "who calls authenticate?"
    A->>M: tools/call cartog_refs {name: "authenticate"}
    M->>DB: SELECT FROM edges WHERE target = ...
    DB-->>M: 12 rows
    M-->>A: JSON {edges: [...]}
    A->>U: answer + file:line citations

On the implementation side, Cartog relies on the rmcp crate (the official Rust MCP implementation).
The binary exposes server mode via cartog serve, launched by the editor at session startup.

stdio only? MCP also defines an HTTP transport.
Why not use it?

Because we don’t need it right now. MCP defines two transports:

  • stdio (the server runs locally, launched by the client)
  • HTTP (the server runs elsewhere, reachable over the network).

Cartog only does stdio, and that’s a deliberate choice consistent with the rest of the series:

  • Local by default. The server is a child process of the editor, on the same machine. The code, the SQLite graph, the queries: nothing travels over the network. Same guarantee as for embeddings computed locally.
  • Zero network configuration. No port to open, no TLS to manage, no auth to wire up. The client launches the binary, we talk over stdin/stdout, end of story.
  • Right scope. The index lives in the repo, on the developer’s machine. Serving this graph over HTTP would imply a remote use case (shared graph in CI, team-wide) that belongs to a different building block — the optional S3 sync, dormant unless explicitly configured.

HTTP transport isn’t ruled out. When a remote use case justifies it, it can be added. MCP allows it without changing any of the 16 tools or their descriptions.

But as long as Cartog is a local tool, adding a network surface also adds complexity.

The server exposes a structured subset of the CLI. Thirteen are read-only; three write (cartog_index, cartog_rag_index rebuild the index, cartog_update prepares a deferred binary update):

ToolRoleWhen the agent calls it
cartog_searchSearch by exact/partial nameBefore refs/callees/impact
cartog_rag_searchSemantic + BM25 searchDefault entry point for any discovery
cartog_contextOne-shot bundle: relevant symbols + bodies for a taskFirst call to frame a task in a single round-trip
cartog_outlineFile structureInstead of Read when you just want the list of symbols
cartog_refsWho calls/imports/inherits X”who calls X?”
cartog_calleesWhat X calls”what does X call?”
cartog_impactTransitive blast radiusBefore a rename/delete/move
cartog_traceShortest call path between two symbols, with inline bodies”how does X end up calling Y?”
cartog_hierarchyInheritance treeFor class / trait
cartog_depsFile importsDirect dependencies
cartog_changesSymbols touched by the last N commits + working treeRegression triage
cartog_mapFile tree + top symbolsFirst call in an unknown repo
cartog_statsIndex health and informationVerify the index is up to date
cartog_index(Re)build the index (writes)Once per session, after a large refactor
cartog_rag_indexBuild the vector index (writes)Once per project, optional
cartog_updatePrepare a deferred binary update (writes machine state, not the index)On explicit update request (useful if the plugin hasn’t updated the binary)

This list is less complete than the CLI arguments.
The commands cartog init, cartog ide, cartog doctor, cartog config remain CLI-only because they write user files or diagnose the environment (these are not operations an agent should trigger on its own).

The Rust code that declares a tool fits in two lines — the description does all the work.

The description field of each tool is read by the LLM on the client side to decide when to call the tool. It’s the user interface of an agent.

Concrete example, the description of cartog_impact:

Assess blast radius before refactoring. Shows everything that transitively depends on a symbol up to N hops.
Use when asked ‘what breaks if I change X?’, ‘is it safe to rename/delete X?’, or before any rename/extract/move/delete refactoring.
Not for: direct callers only (use cartog_refs), or what the symbol calls (use cartog_callees).
Returns: array of {edge, depth} where depth=1 is direct, depth=2 is one hop away, etc.

Four systematic elements:

  1. Description: what the tool does.
  2. Use when: likely phrases from the user (“what breaks if…”, “is it safe to…”).
  3. Not for: competing tools and when to prefer the other one. This is negative routing — without it, the agent picks at random between refs, callees, and impact.
  4. Returns: the output schema, so the agent knows how to parse without guessing.

This pattern is repeated across all tools. The description of cartog_rag_search explicitly mentions “DEFAULT entry point for finding code” — that’s what pushes the agent to use it before Grep.

The description of cartog_search frames the inverse: “use ONLY to get a precise symbol name before calling cartog_refs, cartog_callees, or cartog_impact”.

flowchart TD
    Q["User question"] --> D{"Type of question?"}
    D -->|"concept / natural language"| RAG["cartog_rag_search"]
    D -->|"known symbol name"| S["cartog_search"]
    D -->|"who uses X?"| R["cartog_refs"]
    D -->|"what does X do?"| C["cartog_callees"]
    D -->|"safe to change X?"| I["cartog_impact"]
    S -->|"exact name"| R
    S --> C
    S --> I

    style RAG fill:#e8f5e9,stroke:#4caf50
    style I fill:#fff3e0,stroke:#ff9800

No routing logic is hardcoded in Cartog.
Everything lives in the description text read by the LLM at each turn.

A cartog_impact authenticate --depth 5 on a large project can surface hundreds of edges and quickly exceed what a context window can usefully absorb.

If you send back the full raw response, the agent burns its context window in a single reply.

Cartog therefore enforces a per-response byte cap on the server side (64 KB by default, overridable via CARTOG_MCP_MAX_BYTES). When the output exceeds the threshold, the server truncates at a UTF-8 boundary and appends a truncation notice:

Response truncated: 14 832 bytes omitted to stay under the 65 536-byte cap.
Re-run with a more specific symbol name, or filter by --kind.

The agent receives a usable result and knows what action to take to refine. The hint is specific to each tool: cartog_impact suggests reducing depth, cartog_refs suggests filtering by --kind.

Two design choices behind this:

  • Truncate server-side, not agent-side: the agent can’t measure its own bytes; the server can.
  • Action hint, not just a counter: the notice says what to do, not just how many bytes are missing.

Unlike --tokens (a budget declared by the caller, for human-readable text output), the MCP cap is set server-side, always active, and applies to the JSON returned to the agent.

An operational subtlety: while a cartog serve is alive in the editor, the user might well run cartog index . by hand, or leave cartog watch running which re-indexes continuously.
There can therefore be multiple writers on the same SQLite file.

Three SQLite pragmas are enabled at startup:

  • WAL (write-ahead log): reads and writes can coexist without blocking.
  • busy_timeout: a waiting writer waits N ms instead of immediately returning SQLITE_BUSY.
  • synchronous=NORMAL: durability/performance trade-off suited to a reconstructible index.

Result: the agent can keep querying while indexing runs, without crashes or corruption.
New symbols become visible on the next tool call — no server restart needed.

A question “is it safe to rename authenticate?” becomes, on the agent side:

  1. cartog_search { query: "authenticate" } → 1 exact symbol, id=42.
  2. cartog_impact { name: "authenticate", depth: 3 } → 18 transitive dependents (truncated to 50 if exceeded, with a hint).
  3. Targeted read of the 3-4 most critical call-sites via Read with offset/limit.

Three tool calls, no grep -r, no cat on an entire file. The context window serves reasoning, not digesting 4000 lines.

The agent’s final response, after these three calls:

Yes, renaming `authenticate` is feasible, but 3 things to watch:

- middleware/auth.ts:42 — direct call, rename at the same time.
- tests/auth.test.ts:18 — mock of the exact name as a string literal ('authenticate'),
  update manually.
- UserController.login() → SessionManager.start() → authenticate:
  indirect chain at depth=2, covered if renamed via LSP.

The other 15 dependents are imports of the `auth` module —
they don't reference the function name directly.

Rather than editing config files by hand, cartog ide generates them automatically for supported MCP clients.

Run in a terminal, it displays an interactive selector of detected clients.

cartog ide

Interactive output:

? step 1 — which clients to configure? (space to toggle, enter to confirm) ›
✔ Claude Code      project + user available      will create
✔ Cursor           project                       will create
✔ VS Code          project + user available      will create
✔ Claude Desktop   user                          present, will merge
✔ Windsurf         user                          present, will merge
✔ OpenCode         user                          present, will merge
⬚ Zed              user                          not installed
⬚ Codex CLI        user                          not installed
✔ Gemini CLI       user                          present, will merge
⬚ Antigravity      user                          not installed
✔ Kiro             project + user available      will create
⬚ Hermes Agent     user                          not installed

To preview without writing:

cartog ide --dry-run
+ claude-code (project, .mcp.json): would create
  {
    "mcpServers": {
      "cartog": {
        "command": "cartog",
        "args": ["serve", "--watch"]
      }
    }
  }

For Claude Code only (project scope, .mcp.json):

cartog ide --client claude-code

The result written to .mcp.json:

{
  "mcpServers": {
    "cartog": {
      "command": "cartog",
      "args": ["serve", "--watch"]
    }
  }
}

With --watch, the server watches files and re-indexes on the fly: new symbols are visible on the next tool call, no server restart needed. Without --watch, the index is frozen until the next manual cartog index.

Each client has its own file format and JSON key — cartog ide handles the differences, idempotent merges, and preserves any other MCP servers already present.

For the full client list and manual snippets: MCP documentation.

On the server side, the mechanics are in place: stdio transport, 16 tools, routable descriptions, byte cap, SQLite concurrency.

The agent can query the graph without grep, without cat, without burning its context window on an entire file.

MCP inverts the flow: the agent queries the graph at the right moment, without the user needing to know the commands. cartog ide reduces bootstrapping to one command, and idempotent configs allow versioning .mcp.json in the repo for the whole team.