跳到主要内容

dsh-mcp-workspace-scope

已验证

dsh-mcp-workspace-scope · v0.1.1 · MIT · Web 界面

Per-workspace MCP scoping for the DeepSeek Harness — decide which MCP servers a session may see from the directory it was opened in.

安装

dsh plugin add dsh-mcp-workspace-scope

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

标签

说明文档

dsh-mcp-workspace-scope

English | 简体中文

Listed on dsh-plugin.org npm license

每个项目只注入它真正需要的 MCP——偶尔要破例时,在输入框里给这一个会话拨个开关。

一个 DeepSeek Harness 插件,按会话打开时所在的目录收窄 MCP 工具注入, 并把「破例」做成对话输入框里的会话级开关。

输入框里的 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 web

cordis.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