dsh-mcp-workspace-scope
v0.1.1
Published
Per-workspace MCP scoping for the DeepSeek Harness — decide which MCP servers a session may see from the directory it was opened in.
Maintainers
Readme
dsh-mcp-workspace-scope
English | 简体中文
每个项目只注入它真正需要的 MCP——偶尔要破例时,在输入框里给这一个会话拨个开关。
一个 DeepSeek Harness 插件,按会话打开时所在的目录收窄 MCP 工具注入, 并把「破例」做成对话输入框里的会话级开关。

功能要点
- 按目录给 MCP server 白名单,子目录自动继承
- 既移除工具列表(省上下文),又在调用时拒绝(硬边界)
- 会话级开关就在输入框里:给当前会话临时收窄或放宽,不动规则文件
- 读数是诚实的:显示每台服务器的运行状态,「允许了但是死的」看得见
- 设置页里可视化编辑规则,保存后立即对正在运行的会话生效
为什么需要它
一个 profile 里的 MCP 服务器只会越攒越多,而它们的工具列表会进到每一个会话——
因为 DSH 里 MCP 是全局的:@deepseek-ai/dsh-mcp-client 把工具注册在根 ctx.tools 上,
名字形如 mcp__<serverName>__<toolName>。于是一个只会碰 Jira 的会话,上下文里照样背着
三台数据库和一个浏览器驱动,而且随时可能误调。
这个插件按目录把它收窄:在 D:\work\proj-a 里开的会话只注入 atlassian,
在 D:\work\proj-b 里开的只注入 playwright,其余文件夹保持原样。真要破例的时候——
「接下来十分钟我得用一下 bigquery」——输入框上那个药丸本身就是开关,只管这一个会话。
边界
- 变不出没启用的 server:白名单里的 server 必须先在 profile 里是 enabled
(例如用
dsh-skill-mcp-panel打开)。本插件只能减,不能加。 - 不省进程:被隐藏的 server 照样跑着、照样占内存。要做到「用不到就不启动」, 得把 MCP 行搬进 agent preset,那是另一条路。
- 子智能体是独立判定的:按它自己的工作目录算,而不是继承父会话的限制 (见工作原理)。
安装
dsh plugin --profile web add dsh-mcp-workspace-scope不想走 npm 的话,直接从源码装:
dsh plugin --profile web add github:felix-lj-ct/dsh-mcp-workspace-scope然后重启 profile —— 正在跑的实例内存里还是旧代码:
dsh --profile webcordis.patch.yml 的 bundle 层会自动挂载宿主半区,不需要手改 profile 配置。装完也不会
立刻改变什么:没有规则文件时,所有会话照旧注入全部 MCP(见下)。
规则文件
默认路径 ~/.dsh/mcp-workspace-scope.json($DSH_HOME 生效时跟着走)。
文件不存在 = 插件不生效,所有会话照旧注入全部 MCP —— 装上插件不会改变任何现状。
{
"default": "*",
"rules": [
{
"path": "D:/work/master-data-management",
"servers": ["atlassian", "bigquery"]
},
{
"path": "D:/work/frontend",
"servers": ["playwright", "context7"]
},
{
"path": "D:/scratch",
"servers": []
}
]
}字段语义:
| 字段 | 取值 | 含义 |
| --- | --- | --- |
| default | "*" | 未命中任何规则的文件夹:注入全部(默认值,最安全) |
| default | [] | 未命中的文件夹:一个 MCP 都不注入 |
| default | ["a","b"] | 未命中的文件夹:只注入这几台 |
| rules[].path | 目录路径 | 支持 ~/、$DSH_HOME;正反斜杠都行;Windows 上不区分大小写 |
| rules[].servers | 同 default | 该目录(及其子目录)的白名单 |
匹配规则:
- 子目录继承父目录的规则;边界按路径分隔符判断,所以
/ws/proj不会误匹配/ws/project。 - 最长路径优先:可以用
/ws定基线、再用/ws/proj覆盖。 - 长度相同的重复路径后写的赢。
- 会话没有 cwd(少数情况)时走
default。 - 规则改动立即对正在运行的会话生效(设置页保存、或直接改文件都会触发重算)。
这是刻意的:一个工作区只有一个可复用的空白会话,DSH 在它没被用过时不让你再建一个,
所以「加完工作区 → 设作用域 → 开始干活」要求规则能落到你正看着的这个会话上。
DSH 本身也是这个语义——在 MCP 面板停用一台 server,HMR 会立刻把它的工具从所有
运行中的会话里卸掉。想要旧的冻结行为,把
applyToRunningSessions设为false。
插件配置(可选)
只在需要挪文件位置或改失败策略时才写;加在 profile cordis.patch.yml 对应行的 config: 下:
| 键 | 默认 | 说明 |
| --- | --- | --- |
| rulesPath | "" | 规则文件路径,空 = <DSH home>/mcp-workspace-scope.json |
| enforceGuard | true | 除了隐藏,还在调用时拒绝。建议保持开启(见下) |
| onRulesError | "open" | 规则文件坏了怎么办:open = 全放行(等于插件不存在),closed = 全拦 |
| applyToRunningSessions | true | 规则改动立即重算运行中的会话;false = 每个会话冻结在创建时的规则上 |
| logDecisions | true | 每个会话打一行日志,记录命中了哪条规则、放行了哪些 server |
工作原理
会话在某目录下创建
↓ agent/created
读 session.header.cwd → 最长前缀匹配规则 → 得到白名单
↓
agent.ctx.tools.restrict({ deny: [...不在白名单的 mcp__* 工具] }) ← 从模型可见面移除
agent.ctx.tools.guard(...) ← 调用时拒绝,硬边界
↓ tools/change(server 连上/重连/被卸载)
重算 deny 集合并重挂两个机制并存不是冗余,而是因为它们的时机不同:
restrict()必须在 agent 作用域的 ctx 上调用(根 ctx 调用会被内核拒绝,因为那会屏蔽所有会话), 而且它会校验名字必须是该作用域当前继承到的工具——所以没法为还没连上的 server 预先写 deny。 可见性靠订阅tools/change重算来跟上。guard()是调用时求值、不做名字校验,所以它对「刚注册就被调用」这种缝隙天然免疫。
一个已知边界:subagent 不继承父会话的限制。agentPresets.composeFrom() 把子 agent 的
作用域父节点绑到 preset 的 standing scope,而不是父 agent,所以父会话的 restrict() 到不了子 agent。
本插件对 subagent 会按它自己的 session.header.cwd 独立判一次(通常继承父会话目录,结果一致)。
界面
装上之后,Web UI 有两处体现:
1. 设置页「MCP 作用域」(在设置 → MCP 页下方)

- 顶部显示规则文件路径、失败策略(放行/拦下)、是否拦调用;文件不存在时给出提示。
- 默认(未匹配任何规则的目录):全部 / 无 / 自定义三档,自定义时勾选服务器。
- 目录规则:每条一行,目录可直接编辑;服务器选择器里列出 profile 里所有 MCP 服务器, 显示各自的实时工具数,已停用的会标注「已停用」(仍可勾,但它不会有工具)。
- 添加规则:从已有工作区下拉选一个,或手动填路径。
- 保存后由宿主原子写回规则文件;校验失败会把原因原样显示,不会写坏文件。
- 保存后立即重算正在运行的会话(
applyToRunningSessions: false时才需要新建会话, 此时徽标会明确标出「已冻结」以及当前规则会给什么)。
2. 对话页 composer 工具行的 MCP 徽标
无论有没有命中规则都会显示(这是刻意的:一个「没配置就消失」的能力读数无法用来判断 限制到底有没有生效):
- 未命中规则 →
MCP 全部 - 命中规则 →
MCP atlassian(多个显示atlassian +1) - 命中
[]→MCP 无(黄色)
点开后显示:会话目录、命中的是哪条规则、每台服务器的运行状态、以及可见/隐藏的工具数。 工具数读的是该会话 agent 作用域的真实视图,所以是测量值而不是按规则的推算;会话未运行时 会标注「按规则预测」。
在浮层里直接改本会话的作用域
浮层里每行服务器右侧都有一个开关,整行都是点击区域:拨一下即把这台服务器加入/移出当前 会话,也可以用全部 / 无 / 恢复为规则。写入的响应就是新的读数,所以画出来的一定是 宿主真正装上的。
- 临时的、只在内存里。 不写规则文件,随 agent 一起消失——新建会话(以及宿主重启后) 仍然按目录规则来。
- 设了之后规则改动不再影响本会话。 免得你刚拨过的开关被设置页一次保存悄悄撤销; 点「恢复为规则」即归队。
- 可以放宽,不只是收窄——上限是 profile 里已启用的服务器。这个功能存在的场景就是
「接下来十分钟我要用 bigquery」,只能减的控件解决不了。但仍然变不出停用的服务器
(
restrict()只能减,已启用集合是硬上限)。 - 处于覆盖状态时徽标变蓝色并带
*:这不是警告,只是提醒你「设置页描述的已经不是本会话」。
会话未运行时没有可限制的 agent 作用域,所以那几行不可点,宿主也会直接拒绝写入(400),
而不是报告一个模型根本没拿到的作用域。
「允许了但用不了」
白名单里放 4 台、其中 2 台在 profile 里是停用的,这时作用域看着对、会话却干不了活。所以每台
服务器都带一个状态点(判定逻辑借鉴 dsh-mcp-live-status,同作者 MIT):
| 状态 | 含义 | | --- | --- | | 已连接 | 挂载正常且注册了工具——唯一真正可用的状态 | | 已启动,未连接 | fiber 是 ACTIVE 但一个工具都没注册(握手没成功) | | 启动中 / 挂载失败 / 未挂载 / 已停用 | 其余各态 |
为什么必须拿工具去联结:dsh-mcp-client 默认 failOnStartupError: false,连不上的
server 其 fiber 照样是 ACTIVE,光看挂载状态分不出「活着」和「起来了但是死的」;而
mcp-client 只有在 connect() 与 listTools() 都成功后才注册工具,所以工具注册才是握手成功的证据。
于是徽标会在「已允许但当前不可用」时变黄并加 •,浮层里列出具体是哪几台;白名单里写了
profile 中不存在的名字(拼错、或该服务器已被删)时变红加 !。
顺带修了一个隐蔽的归属 bug:serverName 允许下划线,所以 foo 与 foo__bar 可以并存,
而 mcp__foo__bar__baz 是两者都合法的名字——按第一个 __ 切分会把它判给 foo,导致放行/
拦截判错。现在按最长匹配归属(有专门用例覆盖)。
注意与 dsh-mcp-live-status 的区别:那个插件读的是全局视图(进程里哪台 server 连上了),
所以它始终显示全部已启用的服务器;本插件在此之上叠加「本会话允许哪些」。两者测的不是同一件
事,同时装不冲突,本插件也不依赖它。
权限与风险
这个插件只会减少一个会话的能力,永远不会增加。它能放行的东西必须已经在 profile 里启用; 服务器的启停与配置仍然归设置页管,这里做不到。
| 触及面 | 具体做了什么 |
|---|---|
| ctx.tools | 读已注册工具的名字;给单个 agent 装 restrict() + guard()。从不调用任何工具。 |
| ctx.loader | 只读遍历已配置的插件树,用来列出 MCP 服务器 |
| ctx.reflect | 可选地读 sessions 和 workspaceRegistry——会话 cwd 与已知工作区路径,供读数和路径选择器用 |
| ctx.webServer | /dsh-mcp-workspace-scope 下三条本地 JSON 路由:读状态、读某会话作用域、写规则或会话级覆盖 |
| 网络 | 无任何外发。浏览器半区只 fetch 上面那几条本地路由。 |
| 存储 | 只有一个文件:规则文件(默认 ~/.dsh/mcp-workspace-scope.json),原子写入,且只在你点保存时写。 |
真正需要留意的失效方式是规则比你以为的更严:会话悄悄少了工具,而模型只会说「我做不到」,
不会说「我没被允许」。这正是输入框那个药丸存在的理由——它报的是会话实际拿到什么,
读自 agent 自己的视图。规则文件损坏时默认放行全部(onRulesError),所以一个拼写错误
不会把正在干活的会话废掉;想反过来就设成 closed。
不接触任何凭据。 插件全程只处理服务器名字和工具名字, 从不读 MCP 服务器的命令行、参数或环境变量。
开发
npm install
npm run build # tsc → dist/(dist 随仓库提交,见 .gitignore 里的原因)
npm test # 24 个冒烟用例,用假 harness 跑,不需要 DSH冒烟测试复刻了 ToolRuntime 的三个关键行为(全局视图不受作用域限制影响、restrict()
会对未知名字抛错、restrict() 及其 disposer 都会触发 tools/change),这三条任何一条搞错,
在生产里都是静默失效。测试里还假了一个 webServer,因此 JSON 路由(包括会话级覆盖)是
端到端跑通的;另有一个用例在无 web server 的情况下运行,确保收窄本身从不依赖它。
License
MIT
