Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP

drsg-mcp is a Model Context Protocol server that embeds Dr Strange and exposes the database to LLM agents as a set of tools. An agent in a compatible host can search, traverse, query, run algorithms, ask natural-language questions, and ingest documents — and write to the graph — directly, without bespoke integration code.

How Dr Strange fits an agent

MCP is itself JSON-RPC 2.0 — the same protocol the web backend speaks — so an MCP server is a first-class surface rather than an adapter. drsg-mcp embeds dr-strange-core and opens the database in-process (as the CLI does), then serves the protocol over stdio: the host launches the process and exchanges JSON-RPC messages over its standard input and output. Logs are written to stderr and a rolling file, never to stdout, which carries the protocol.

Running and configuring

drsg-mcp is a separate binary. Install a release with one line (Chapter 2):

$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh -s -- --bin drsg-mcp

On Windows, in PowerShell:

PS> & ([scriptblock]::Create((irm https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.ps1))) -Bin drsg-mcp

It can equally be built from source with cargo build --release -p dr-strange-mcp. It takes the database as --db <path> or as a bare argument, else $DRSG_DB, else graph.drsg; --help and --version print and exit:

$ drsg-mcp --db /path/to/graph.drsg
$ drsg-mcp /path/to/graph.drsg          # the same thing, short

The database must already exist — this server never creates one. Build it with drsg digest <dir> --apply --db <path>, or with drsg init in a repository. An empty database would answer every question with “nothing found”, which reads exactly like a digest that went wrong, so a path that isn’t there is an error instead.

The database is opened with the same history retention drsg serve and the CLI use — the last 20 commits reachable by time travel, older versions reclaimed at compaction — so a store an agent writes through write_nodes, write_edges, cypher or digest does not keep every version forever. This binary reads no config file; DRSG_RETAIN_COMMITS changes the figure (0 keeps everything, as [server] retain_commits = 0 does), and a value that is not a number is an error at start-up rather than a silent default.

With no database named, it looks for a server first. The nearest .mcp.json is read — walking up, as git finds its own directory, and stopping at the repository root (the first .git); a file owned by another user or writable by everyone is passed over, since what it names receives the host’s session and the Authorization it carries — and if it declares a drsg server that answers, this process relays the host’s session to it instead of opening anything. So in a repository prepared by drsg init (which digests the tree, starts a drsg serve … watch, and writes that server’s URL into .mcp.json), a stdio-only host reaches the process that already holds the database, with a plane synced to the repository’s commits.

The relay forwards messages as they are, in both directions, so the host sees that server’s tool set — including tools a newer server has and this binary has never heard of. Naming a database (--db, $DRSG_DB) skips the search: a caller who says which graph they want is not asking to be sent to another one. Nothing answering, no .mcp.json, or no drsg server in it, and the database is opened here as before.

It is normally launched by the host rather than run by hand. A host configures it by command, arguments, and environment — the environment carries any LLM provider keys the graph tools need:

{
  "mcpServers": {
    "dr-strange": {
      "command": "drsg-mcp",
      "args": ["--db", "/path/to/graph.drsg"],
      "env": {
        "OPENAI_API_KEY": "sk-...",
        "DEEPSEEK_API_KEY": "...",
        "DASHSCOPE_API_KEY": "..."
      }
    }
  }
}

A ready-to-edit copy of this configuration is provided at crates/dr-strange-mcp/mcp.json; set command to drsg-mcp (on the PATH) or an absolute path to the binary, point args at your database, and supply only the provider keys you use.

Because it opens the database in-process, one process at a time may open a given database directly — a drsg-mcp, a drsg command, or a drsg serve, but not two at once. This is enforced, not advisory: the second open fails with a clear error rather than corrupting the database.

That is the rule the relay above exists to keep out of your way, and it still binds whenever the relay does not apply. Two editors open on the same project each spawn their own drsg-mcp: with a watch server running, both relay to it and share one database; with none, the first opens the file and the second refuses to start. For that second case — several agents that need to share one memory — see the next section.

Sharing across agents: /mcp on drsg serve

drsg-mcp embeds by design: point a host at a path and it works, with nothing to run and nothing to configure. That’s the right answer for one agent, but it’s also why two hosts can’t share a database directly — each one opens the file itself.

drsg serve (arch/08) already solves the “one writer, several clients” problem for the JSON-RPC API and the dashboard. It exposes the identical tool set at POST /mcp, over MCP’s Streamable HTTP transport, so several agent hosts can point at the same server instead of each embedding their own copy of the database:

{
  "mcpServers": {
    "dr-strange": {
      "url": "http://127.0.0.1:7700/mcp",
      "headers": { "Authorization": "Bearer <DRSG_TOKEN>" }
    }
  }
}

(Check your host’s docs for its exact remote-MCP-server config shape — the url/headers fields above are illustrative, not universal.)

/mcp is gated exactly like /rpc: with DRSG_TOKEN unset, only the same-origin browser UI is trusted, and every programmatic client is refused, reads included — a zero-config desktop install shouldn’t quietly expose an open API on localhost. Set DRSG_TOKEN on the server and pass it as a bearer token to reach /mcp from anywhere else, including another agent host.

One thing to know before putting this behind a hostname: the MCP transport validates the inbound Host header to blunt DNS-rebinding attacks on locally running servers, and answers 403 to a name it does not expect. Loopback (localhost, 127.0.0.1, ::1) is always accepted. Once a bearer token is configured, the address the server is bound to is accepted too, and so is every name in DRSG_ALLOWED_HOSTS (comma-separated, a hostname or host:port) — so /mcp behind https://memory.example.com works with DRSG_ALLOWED_HOSTS=memory.example.com. Without a token the list is loopback only, whatever the environment says: a tokenless server trusts its own same-origin UI, which is exactly what a rebinding page sets out to impersonate, and the Host check is what defeats it. With a token every request has to carry a secret such a page cannot read, so the guard adds nothing the token does not, and the operator may widen it. Agent hosts on the same machine, the case ROADMAP §10 is about, need none of this.

Every session gets its own DrStrange instance, but they all share the one Database the server opened — a write from one session is visible to every other, immediately, the same way two browser tabs against /rpc already are. That’s the whole point: write_gate inside the core serializes concurrent writers so this is safe, not just convenient.

digest’s LLM calls still spend the server process’s provider keys (never a tool parameter, remote or local); write_nodes/write_edges keep their per-call batch atomicity, since the tool code runs in the same process against the same Database either way — nothing here proxies to /rpc or reshapes what a tool does. The [digest] config section steers the digest tool exactly as it steers digest.run over /rpc: lowering concurrency to stay under a provider’s rate limit applies to both surfaces, not one of them. (The embedded drsg-mcp binary has no config file and keeps the built-in defaults.)

rebuild re-reads the whole tree a plane was built from, which is what an answer means when it says the plugins changed since the plane was built — a fold applies a newly installed parser only to the files a commit touched. It takes a plane name and nothing else: the directory comes from the plane’s own recorded synced_root, so a rebuild cannot be aimed at a tree the plane was never built from. It costs a full re-parse of the repository, and clears the warning that prompted it. See the Plugins chapter for what the warning means.

One capability differs by transport rather than by configuration. digest accepts a path to a document the server reads — Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, PDF, Markdown or plain text — and only the stdio server honours it. That server runs on your own machine as your own user, so it reads exactly what the agent could already open for itself. A shared drsg serve refuses it: reading any path the caller names would let an authenticated remote agent pull arbitrary server files into the graph and query them back out. Over /mcp, send the document as text instead.

The same line runs through grep and snippet, which read source files. Every path they open — an argument like src/lib.rs:12 or a node’s file property — must resolve inside the tree it belongs to: .., an absolute path and a symlink pointing out of the checkout are refused, never read. Which trees exist depends on the transport. The tree the operator attached (serve watch’s directory, or [server] source_root) is always readable. A plane’s own synced_root is a plane property — data — so the stdio server, running as you, reads it wherever it points, while a shared drsg serve reads it only when it lies inside the attached tree; with no tree attached, /mcp reads no file at all.

Session lifetime

A host that exits cleanly sends DELETE /mcp and its session goes away at once. A host that is SIGKILLed — an editor restarting its MCP child, say — sends nothing, so the server reclaims that session on a timer instead: 10 minutes idle, or 60 seconds with no initialize after the session is created. The session’s worker task, its DrStrange, and its buffered messages all go with it.

The idle window is ten minutes rather than five for a specific reason: the transport counts a running tool as idle, because its keep-alive timer is only reset by traffic and a tool call sends nothing between dispatch and its result. On an otherwise quiet session, a tool that runs longer than the window is torn down mid-flight. Ten minutes clears any realistic digest; if you routinely run longer ones, keep the session busy or expect to retry.

What survives a reclaim is one map entry per dead session. It is tens of bytes, but it is not inert: the next request on that session id is answered 500 rather than the 404 the spec calls for, so a client that would have re-initialized does not, and that session stays broken until the host restarts. Both of these are transport-level and fixed upstream rather than here. Closing sessions you script in a loop avoids the whole area.

Tool concurrency

Tool calls are bounded separately from HTTP requests, at 16 at once across the whole process (or max_concurrent, if that is lower). The two ceilings are not the same thing: max_concurrent counts requests, most of which are cheap, while every tool call is a full-graph scan, a bulk write, or an LLM-fanning digest. The transport also answers a tool call as soon as it is queued, so the request ceiling has already released the call before the work begins and cannot bound it. Excess calls queue rather than fail — a busy server makes an agent wait, it does not turn it away — but not without end: every call has one deadline covering its wait and its run, 300 s unless DRSG_MCP_TOOL_DEADLINE_SECS says otherwise (0 disables it). A call that passes it comes back as a tool error saying whether the server was busy or the body was slow, so the agent can retry or narrow the request rather than sit behind a call that never returns. A body already running is not cut short, but it keeps its slot until it finishes, so the ceiling still counts it.

The tools

ToolKindPurpose
list_planesreadlist planes with node/edge counts
describe_planereada plane’s soft schema (labels, properties, edge types, key prefixes)
get_nodereadfetch one node by id or external key
searchreadsemantic lookup — embeds the query, returns the k nearest nodes
contextreadone symbol’s whole neighborhood on a digested code plane — the primary agent verb
describereadone symbol’s properties, the lightweight node-only view
grepreadtext search over the watched source tree — literal or regex, scoped by path, with context lines; each hit names the symbol it falls in; ignored files are excluded unless all_files
tracereadthe shortest recorded call path between two symbols
impactreadeverything reaching a symbol, grouped by distance, with what the parse could not bind counted beside the total and never in it; across (attached or all) also names the other repositories the radius reaches, by the coordinate their build declares and the symbol their source stands in for
fathomreadthe makeup of the region around a symbol — labels, edge types, hubs
snippetreada symbol’s source text, or a range of a file (path:start-end); says which symbol a range opens in and how to read on
historyreada repository’s history at a glance — where HEAD is, what the branches and tags point at, what was rebased, and the newest commits; reads the _git plane a digest of a git checkout writes
deployedreadwhat commit a running thing was built from, and what runs a commit — the _ops plane a digest of manifests writes, joined to the _git plane beside it through the revision an image’s tag carries; a floating tag, a digest-only reference and a revision this history does not hold are each said in those words rather than answered with nothing
recallreadcode as it was at a git revision — a file, a line range, a directory, or a symbol’s declaration then, found by parsing its file as it was; pattern searches that tree and vs diffs a name from an older revision; the revision resolves over the _git plane
wirereadwhat serves an HTTP path and what asks for it — routes matched across every language in the plane, and across the planes it attaches; computed per call, never stored
attachwriterecord a plane this one may ask: one on this instance, or one another drsg serves (url); both records the way back
detachwriteforget an attachment by the name it was made under
attachmentsreadwhat a plane attaches; probe asks each one served elsewhere whether it answers now
mountwriteserve the planes of another database alongside this one’s, now, without a restart — one process for many repositories, each keeping its own store, its own writer and its own plane names. Confined to [server] mount_root
unmountwritestop serving a mounted database, releasing its store’s lock and stopping the watcher that folded into it; freed says whether the lock came free, since a fold in flight holds it until it ends. Nothing is deleted
mountsreadwhat this server serves and where each part came from — every mount with its directory and planes, and every plane with the tree it was parsed from, which is how a client maps a working directory to a plane
traversereadneighborhood expansion from a node (1+ hops)
queryreadrun a serialized logical plan
cypherread / writerun an openCypher-subset statement — the escape hatch for what no verb anticipated; DELETE/REMOVE require confirm: true
algoreada graph algorithm (pagerank / components / shortest_path / louvain)
hybridreadfused vector + keyword + graph-proximity search
askreada natural-language question, compiled to a plan and run
write_nodeswritecreate nodes (batched)
write_edgeswritecreate edges (batched) by endpoint keys
create_planewritecreate an empty plane
drop_planewritedelete a plane and its contents (requires confirmation)
digestwriteingest a document (dry-run by default; apply: true requires confirm: true; mode sets extraction precision)
rebuildwritere-read the whole tree a plane was built from, making a graph that was parsed by two plugin sets one parse again

Mapping to the rest of the system

The tools are the same operations available through the CLI and the JSON-RPC API, adapted to an agent’s needs: search / traverse / query / cypher / algo / hybrid / ask mirror the query and retrieval surface of Chapter 4 and Chapter 3; write_nodes / write_edges / create_plane / drop_plane / digest mirror the write and ingestion surface. Each is grounded in the plane’s soft schema, which describe_plane exposes so an agent can discover a graph before acting on it.

Naming a node exactly

A node’s external key is prefixed, and the prefix is the part no convention reveals. A Rust key is prefixed with the crate (dr_strange_core::compact::resolve); a TypeScript key is prefixed with the name declared in the nearest package.json, then the file’s path relative to the directory holding that manifest, without the extension — a trailing /index collapses to its directory, and a root index names the package itself. With no package.json above the file, the prefix is the tree’s label, which for a local directory is its name.

The consequence worth knowing: a TypeScript key is not prefixed with the repository directory. A project whose directory is bridge but whose manifest declares highway_bridge-3.0 has keys under highway_bridge-3.0/, and a lookup by directory name finds nothing.

Two things make this discoverable rather than something to remember. describe_plane reports key_prefixes — the prefixes each producer’s nodes actually use, observed by scanning rather than declared, so it cannot drift from what is stored. And a lookup that finds nothing names the prefixes the plane does hold, because a name matches any fragment of a key: src/App finds highway_bridge-3.0/src/App without knowing the prefix at all.

Safety

  • Provider keys are read from the server’s environment, never from tool parameters — an agent cannot exfiltrate or supply a key through a call.
  • Reads are non-destructive. ask in particular compiles to a read-only plan and cannot mutate the graph.
  • Destructive writes are guarded; additive ones are not. drop_plane, a cypher statement carrying DELETE or REMOVE, and digest with apply: true all require confirm: true and refuse without it, naming the flag; digest defaults to a dry run that returns the proposed nodes and edges for inspection rather than writing them. write_nodes, write_edges and CREATE/MERGE/SET need no confirmation: they add what a later delete can remove, and a flag demanded for everything is one a client learns to pass without thinking.
  • A mirrored plane is not rewritten by hand. A plane that digest or serve watch keeps in step with a source tree records the commit it reflects, and cypher refuses CREATE/MERGE/SET/REMOVE/DELETE on it: the next fold reconciles the plane against the tree, overwriting edits to parser-owned nodes and re-creating deletions, so such a write would be undone without a trace. write_nodes / write_edges still work there — a fold leaves nodes it does not own alone — and a plane of the agent’s own takes every statement.

On those same mirrored planes cypher answers a RETURN n in the compact text the agent verbs speak — a count, the synced commit, one line per node — rather than a JSON record apiece; a projection is a table everywhere. And a statement that fails to parse comes back with the grammar of the clauses it reached for, so the tool listing carries the intent and two examples rather than the whole language on every turn.

Example: an agent workflow

A typical agent session composes the tools:

  1. describe_plane to learn the labels, properties, and edge types in scope.
  2. search or hybrid to retrieve relevant nodes, then traverse to gather their neighborhood — grounded context for the model.
  3. ask or cypher to answer a specific question against the graph.
  4. digest (dry run, then applied) to fold new source material into the graph as entities and relations.

Because the store is a graph, the agent can move from retrieval into traversal in a single session — the GraphRAG loop of Chapter 1, driven end to end by the model.