MCP
drsg-mcp 是一个 Model Context Protocol
服务,它内嵌 Dr Strange,并将数据库作为一组工具暴露给 LLM 智能体。兼容的宿主中,
智能体可以直接检索、遍历、查询、运行算法、以自然语言提问、导入文档,也可以写入
图,全程无需专门的集成代码。
Dr Strange 如何契合智能体
MCP 本身就是 JSON-RPC 2.0,与 Web 后端说的是同一套协议,因此 MCP 服务是一等的
接口,而不是一层适配器。drsg-mcp 内嵌 dr-strange-core,在进程内打开数据库
(和命令行一样),再通过 stdio 提供该协议:宿主启动这个进程,双方在标准输入
输出上交换 JSON-RPC 消息。日志写入 stderr 与一个滚动文件,绝不写入承载协议的
stdout。
运行与配置
drsg-mcp 是一个独立的二进制。一行命令即可安装发行版本
(第 2 章):
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh -s -- --bin drsg-mcp
在 Windows 的 PowerShell 中:
PS> & ([scriptblock]::Create((irm https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.ps1))) -Bin drsg-mcp
它同样可以从源码构建:cargo build --release -p dr-strange-mcp。数据库以
--db <路径> 指定,也可以直接作为一个裸参数给出;其次读 $DRSG_DB,再退回到默认
值 graph.drsg。--help 与 --version 只打印信息随即退出:
$ drsg-mcp --db /path/to/graph.drsg
$ drsg-mcp /path/to/graph.drsg # 同样的意思,简写
数据库必须已经存在——这个服务从不创建数据库。用 drsg digest <目录> --apply --db <路径> 建立,或在仓库中运行 drsg init。空数据库对任何问题都只会答「什么也没
找到」,这和一次出了错的图化毫无区别;因此路径不存在时直接报错。
未指名数据库时,它会先去找一个服务。 它读取最近的 .mcp.json——像 git 找自己的
目录那样逐级向上,到仓库根(第一个 .git)为止;不属于当前用户或任何人可写的文件会被
跳过,因为它指名的服务将接收宿主的会话及其携带的 Authorization——若其中声明了一个
drsg 服务且该服务有应答,本进程便把宿主的会话
中继过去,而不再打开任何数据库。于是在一个由 drsg init 准备好的仓库里(它图化
源码树、拉起一个 drsg serve … watch,并把该服务的 URL 写进 .mcp.json),只会说
stdio 的宿主同样能接到那个已经持有数据库的进程,其平面还跟随着仓库的提交(另见
下一节)。
中继按原样双向转发消息,因此宿主看到的是那个服务的工具集——包括较新的服务才有、而
本二进制从未听说过的工具。指名了数据库(--db、$DRSG_DB)则跳过这次查找:既然
调用者说明了想要哪张图,就不该被送去另一张。若无人应答、没有 .mcp.json,或其中
并无 drsg 服务,就依旧在本进程里打开数据库。
它通常由宿主启动,而不是手动运行。宿主通过命令、参数与环境变量来配置它,图工具 所需的 LLM 提供方密钥就放在环境变量里传入:
{
"mcpServers": {
"dr-strange": {
"command": "drsg-mcp",
"args": ["--db", "/path/to/graph.drsg"],
"env": {
"OPENAI_API_KEY": "sk-...",
"DEEPSEEK_API_KEY": "...",
"DASHSCOPE_API_KEY": "..."
}
}
}
}
crates/dr-strange-mcp/mcp.json 提供了一份可直接编辑的该配置副本:把 command
设为 drsg-mcp(位于 PATH 中)或该二进制的绝对路径,让 args 指向你的数据库,
只提供你实际用到的提供方密钥。
由于它在进程内打开数据库,同一时刻只能有一个进程能直接打开某个数据库:可以是
一个 drsg-mcp、一条 drsg 命令,或一个 drsg serve,但不能两个同时打开。这
一点是强制的,不只是建议——第二次打开会直接报错失败,而不会把数据库弄坏。
上面的中继,正是为了让这条规则不再挡路而存在;而在中继不适用之处,它依旧生效。同一
个项目开着两个编辑器,就会各自派生一个 drsg-mcp:有 watch 服务在跑时,两者都中继
到它,共享同一个数据库;没有时,先来的打开文件,后到的拒绝启动。若干智能体需要共享
同一份记忆的场景,见下一节。
跨智能体共享:drsg serve 上的 /mcp
drsg-mcp 按设计内嵌运行:把宿主指向一个路径即可工作,无需运行任何附加服务,也
无需配置。这对单个智能体是正确答案,但也正因为如此,两个宿主没法直接共享一个
数据库:两边都要打开同一个文件。
drsg serve(arch/08)已经为 JSON-RPC 接口和仪表盘解决了"一个写者、多个客户端"
的问题。它在 POST /mcp 上暴露完全相同的工具集,走 MCP 的 Streamable HTTP 传输,
让多个智能体宿主可以指向同一个服务,不必各自内嵌一份数据库副本:
{
"mcpServers": {
"dr-strange": {
"url": "http://127.0.0.1:7700/mcp",
"headers": { "Authorization": "Bearer <DRSG_TOKEN>" }
}
}
}
(远程 MCP 配置的确切格式请查阅你所用宿主的文档。上面的 url/headers 字段只是
示例,不是通用格式。)
/mcp 的鉴权方式与 /rpc 完全一致:未设置 DRSG_TOKEN 时,只有同源的浏览器 UI
受信任,任何程序化客户端都会被拒绝,读操作也不例外,因为零配置的桌面安装不该在
本地悄悄暴露一个开放的 API。要从其他地方(包括另一个智能体宿主)访问 /mcp,请
在服务端设置 DRSG_TOKEN,并以 bearer token 的形式传递它。
把它放到某个域名后面之前,有一点需要先知道:MCP 传输层会校验请求的 Host 头,
借此削弱针对本机服务的 DNS 重绑定攻击,对不认识的名字返回 403。回环地址
(localhost、127.0.0.1、::1)始终放行。配置了 bearer token 之后,服务端绑定
的地址也会放行,DRSG_ALLOWED_HOSTS(逗号分隔,主机名或 host:port)里的每个
名字同样放行——所以把 /mcp 放到 https://memory.example.com 后面,只需
DRSG_ALLOWED_HOSTS=memory.example.com。未设 token 时,无论环境变量怎么写,列表
都只有回环地址:未设 token 的服务端信任自己的同源 UI,而重绑定页面要冒充的正是
这个身份,Host 检查就是用来挡它的。有了 token,每个请求都必须携带这种页面读不
到的秘密,这道检查就不再比 token 多挡住什么,运营者可以放宽它。ROADMAP §10 针对
的场景——同一台机器上的多个智能体宿主——什么都不用配。
每个会话都会拿到自己的 DrStrange 实例,但它们共享服务端打开的同一个
Database:一个会话的写入会立刻对其他所有会话可见,就像两个浏览器标签页访问
/rpc 那样。这正是关键所在——核心内部的 write_gate 会把并发写者串行化,所以
这么做是安全的,不只是方便。
rebuild 会重新读取某个 plane 建立时所依据的整棵树——当一个回答说「自本 plane 建立
以来插件发生了变化」,指的正是这件事:折叠只会把新装的解析器作用于某次提交触及的文
件。它只接受一个 plane 名字:目录来自该 plane 自己记录的 synced_root,因此重建无
法被指向一棵它从未据以建立的树。代价是把整个仓库重新解析一遍,完成后促使它被调用的
那条警告也随之消失。这条警告的含义见插件一章。
有一项能力的差异来自传输方式本身,而不是配置。digest 接受一个 path,由服务端
读取该文档(Word、PowerPoint、Excel、OpenDocument、RTF、EPUB、CSV、PDF、Markdown
或纯文本),但只有 stdio 服务接受这种用法:这个进程运行在你自己的机器上、以你
自己的身份运行,它读到的文件本来就是 agent 能打开的文件。共享的 drsg serve 就
会拒绝这么做——一旦允许调用方指定任意路径,一个已通过认证的远程 agent 就能把
服务器上的任意文件读进图里,再用查询取回。在 /mcp 上,请改为直接以 text 传入
文档内容。
digest 的 LLM 调用,花的始终是服务进程自身的提供方密钥(不管本地还是远程,都
绝不会来自工具参数)。write_nodes/write_edges 仍然保留逐次调用的批量原子性,
因为不管走哪条路径,工具代码都在同一进程内针对同一个 Database 运行,不会代理到
/rpc,也不会改变任何工具的行为。[digest] 配置段对 digest 工具的作用,和它对
/rpc 上 digest.run 的作用完全一致:调低 concurrency 来规避提供方限流时,
两个入口都会遵守,不存在一个听、另一个不听的情况。(内嵌的 drsg-mcp 二进制没有
配置文件,沿用内置默认值。)
会话生命周期
正常退出的 host 会发送 DELETE /mcp,会话随即消失。被 SIGKILL 的 host(比如
编辑器重启它的 MCP 子进程)什么都不会发,服务端就改用定时器回收:空闲超过 10
分钟,或者会话创建后 60 秒内没有收到 initialize。这时该会话的 worker task、它的
DrStrange,以及缓冲的消息都会一并释放。
空闲窗口取 10 分钟而不是 5 分钟,原因很具体:传输层会把正在执行的工具调用也算作
空闲,因为它的 keep-alive 计时器只认流量,而一次工具调用从派发到返回结果之间不会
发送任何东西。所以在一个本就安静的会话上,运行时间超过这个窗口的工具会被中途
拆断。10 分钟足以覆盖任何现实场景中的 digest;如果你经常要跑更久的任务,要么让
会话保持有流量,要么就要预期它可能需要重试。
回收之后,每个死会话都会留下一条 map 条目。它只有几十字节,但并非无害:这个
会话 id 上的下一个请求会收到 500,而不是规范要求的 404,于是本该据此重新
initialize 的客户端不会重连,这个会话就一直坏着,直到 host 重启为止。这两点都是
传输层的问题,该在上游修,而不是在这里绕过去。如果你在脚本里循环创建会话,记得
手动关闭,就能完全避开这个问题。
工具并发
工具调用和 HTTP 请求是分开限流的:全进程同时最多 16 个工具调用(如果
max_concurrent 更小,则取更小的那个)。二者不是一回事——max_concurrent 统计的
是请求,其中大多数开销很低;而每次工具调用,要么是一次全图扫描、一次批量写入,
要么是一次向 LLM 扇出的 digest。而且传输层在工具调用刚被入队时就会应答,请求
上限在工作真正开始之前就已经释放了,根本管不到它。超出的调用会排队而不是直接
失败:服务器繁忙时该让 agent 等,而不是把它拒之门外——但不会无限期地等:每次
调用有一个覆盖排队与执行的截止时间,默认 300 秒,可用
DRSG_MCP_TOOL_DEADLINE_SECS 调整(0 关闭)。超时的调用以工具级错误返回,并说明
是服务器繁忙还是执行过慢;已在运行的调用体不会被中断,但会一直占着它的名额直到
结束,所以上限仍然算得上它。
工具
| 工具 | 类别 | 用途 |
|---|---|---|
list_planes | 读 | 列出各平面及其节点/边计数 |
describe_plane | 读 | 某个平面的软 schema(标签、属性、边类型、键前缀) |
get_node | 读 | 按 id 或外部键获取一个节点 |
search | 读 | 语义查找——嵌入查询文本,返回最近的 k 个节点 |
context | 读 | 已图化代码平面上一个符号的完整邻域——首选的智能体动词 |
describe | 读 | 一个符号的属性,只看节点的轻量视图 |
grep | 读 | 在被监视的源码树上做文本检索——字面或正则,可用 path 限定范围并带上下文行;每个命中都标出所在的符号;被 ignore 的文件除非指定 all_files 否则不在范围内 |
trace | 读 | 两个符号之间图中记录的最短调用路径 |
impact | 读 | 所有能到达该符号的东西,按距离分组,解析无法确定绑定的那些只计在总数旁边、从不计入总数;across(attached 或 all)还会指出这个半径触及了哪些其他仓库——依据它们构建声明的坐标,以及它们源码为之立下替身的符号 |
fathom | 读 | 一个符号周边区域的构成——标签、边类型与枢纽 |
snippet | 读 | 一个符号的源码文本,或文件的某个行区间(path:start-end);会说明区间落在哪个符号里、如何继续往下读 |
history | 读 | 一个仓库历史的概览——HEAD 在哪、分支与标签指向何处、哪些分支被变基过,以及最新的若干提交;读的是 git 检出目录被图化时一并写入的 _git 平面 |
deployed | 读 | 某个运行中的东西是由哪次提交构建的,以及哪些东西在跑某次提交——把清单被图化时写入的 _ops 平面,经由镜像标签所携带的修订号,连到它旁边的 _git 平面;浮动标签、只带 digest 的引用,以及这段历史并不持有的修订号,都会被如实说出来,而不是用“什么都没有”来回答 |
recall | 读 | 某个 git 修订版本时的代码——一个文件、一个行区间、一个目录,或一个符号当时的声明(按当时的文件重新解析得到);pattern 在那棵树上检索,vs 给出一个名字相对更早修订版本的差异;修订版本经由 _git 平面解析 |
wire | 读 | 什么在提供某个 HTTP 路径、什么在请求它——跨平面内的每种语言匹配路由,也跨它所附着的平面;每次调用时计算,从不存储 |
attach | 写 | 记录本平面可以询问的平面:本实例上的,或由另一个 drsg 提供服务的(url);both 会把返程也记下来 |
detach | 写 | 按附着时所用的名称移除一条附着 |
attachments | 读 | 本平面附着了什么;probe 会逐个询问位于别处的附着现在是否应答 |
mount | 写 | 立即把另一个数据库的平面与本数据库的一同提供服务,无需重启——一个进程服务多个仓库,每个仓库保留自己的存储、自己的写入者与自己的平面名。受 [server] mount_root 限制 |
unmount | 写 | 停止提供某个已挂载的数据库,释放其存储的锁,并停止向它折叠的那个 watcher;freed 说明该锁是否已经释放——仍在进行中的折叠会持有它直到结束。不删除任何东西 |
mounts | 读 | 本服务器提供了什么、各部分从何而来——每个挂载及其目录与平面,以及每个平面被解析自的那棵树;客户端正是据此把工作目录映射到平面 |
traverse | 读 | 从某个节点进行邻域扩展(1 跳及以上) |
query | 读 | 运行一个序列化的逻辑计划 |
cypher | 读 / 写 | 运行一条 openCypher 子集语句——任何具名动词都未预见的问题,由它兜底;DELETE/REMOVE 需要 confirm: true |
algo | 读 | 一个图算法(pagerank / components / shortest_path / louvain) |
hybrid | 读 | 融合的向量 + 关键词 + 图邻近度检索 |
ask | 读 | 一个自然语言问题,编译为计划并执行 |
write_nodes | 写 | 创建节点(批量) |
write_edges | 写 | 按端点键创建边(批量) |
create_plane | 写 | 创建一个空平面 |
drop_plane | 写 | 删除一个平面及其内容(需确认) |
digest | 写 | 导入一篇文档(默认为 dry-run;apply: true 需要 confirm: true;mode 决定抽取精度) |
rebuild | 写 | 重新读取某个 plane 建立时所依据的整棵树,让一张被两套插件分别解析过的图重新成为一次完整的解析 |
与系统其余部分的映射
这些工具做的事,和命令行、JSON-RPC 接口能做的完全一样,只是照顾了智能体的使用
方式:search / traverse / query / cypher / algo / hybrid / ask 对应
第 4 章与第 3 章的查询与检索接口;
write_nodes / write_edges / create_plane / drop_plane / digest 对应写入
与导入接口。每个工具都依据该平面的软 schema 行事,而 describe_plane 就是用来
暴露这份 schema 的,让智能体能在动手之前先了解这张图。
精确地指定一个节点
节点的外部键带前缀,而前缀恰恰是任何命名习惯都看不出来的那一段。Rust 键以 crate
为前缀(dr_strange_core::compact::resolve);TypeScript 键以最近一个
package.json 里声明的 name 为前缀,其后是该文件相对于这个清单所在目录的
路径,去掉扩展名——结尾的 /index 折叠为其所在目录,而根目录的 index 就代表这
个包本身。若文件之上没有任何 package.json,前缀取这棵树的标签,对本地目录来说
就是目录名。
于是有一条值得记住的推论:TypeScript 键并不以仓库目录为前缀。一个目录名叫
bridge、而清单里声明为 highway_bridge-3.0 的项目,它的键都在
highway_bridge-3.0/ 之下,按目录名去查将一无所获。
有两处让这件事可被发现,而不必靠记:describe_plane 会给出 key_prefixes——每个
产出方的节点实际使用的前缀,由扫描得来而非声明得来,因此不会与已存的键脱节;而一
次没有命中的查找,会列出该平面确实持有的前缀,因为名字是按键的片段匹配的:
src/App 无需知道前缀,也能找到 highway_bridge-3.0/src/App。
安全
- 提供方密钥从服务端环境读取,绝不从工具参数读取——智能体无法通过一次调用 外泄或提供密钥。
- 读操作是非破坏性的。 尤其是
ask,它编译为一个只读计划,无法改动图。 - 破坏性写入受到保护,增量写入则不受。
drop_plane、带DELETE或REMOVE的cypher语句、以及apply: true的digest都需要confirm: true,缺少时会 拒绝并指出该标志;digest默认为 dry-run,返回拟议的节点与边以供检视,而非直接 写入。write_nodes、write_edges和CREATE/MERGE/SET无需确认:它们添加的 内容可由之后的删除撤销,而一个事事都要的标志只会被客户端不假思索地传上。 - 文件只从源码树内读取。
grep与snippet打开的每个路径都必须解析到它所属 的树之内:..、绝对路径、指向树外的符号链接一律拒绝。操作者挂接的树(serve watch的目录或[server] source_root)始终可读;平面自身的synced_root是 数据,stdio 服务以你的身份运行、可以照读,而共享的drsg serve只在它位于挂接 的树内时才读;未挂接任何树时,/mcp不读任何文件。 - 镜像平面不接受手工改写。 由
digest或serve watch与源码树保持同步的 平面记录着它所反映的提交,cypher在这类平面上拒绝CREATE/MERGE/SET/REMOVE/DELETE:下一次折叠会把平面与源码树重新对 账,覆盖对解析器所有节点的修改、重建被删除的节点,这样的写入会被无声地撤销。write_nodes/write_edges在那里仍然可用——折叠不会动它不拥有的节点—— 而智能体自己的平面接受任何语句。
在同样这些镜像平面上,cypher 对 RETURN n 以智能体动词共用的紧凑文本作答——
一个计数、同步的提交、每个节点一行——而不是每个节点一条 JSON 记录;投影查询在
任何平面上都返回表格。语句解析失败时,返回会附上它所触及的那几条子句的语法,
因此工具清单只需承载意图与两个例子,而不必每一轮都携带整门语言。
示例:一个智能体工作流
一次典型的智能体会话会组合这些工具:
describe_plane,以了解作用范围内的标签、属性与边类型。search或hybrid检索相关节点,再以traverse收集其邻域,为模型提供扎实 的上下文。ask或cypher,针对图回答一个具体问题。digest(先 dry-run,再应用),将新的原始材料作为实体与关系并入图。
由于存储的是一张图,智能体可以在同一个会话里从检索直接转入遍历——这正是 第 1 章所说的、由模型端到端驱动的 GraphRAG 循环。