# dsh-mcp-workspace-scope

[English](README.md) | 简体中文

[![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/felix-lj-ct/dsh-mcp-workspace-scope)
[![npm](https://img.shields.io/npm/v/dsh-mcp-workspace-scope)](https://www.npmjs.com/package/dsh-mcp-workspace-scope)
[![license](https://img.shields.io/npm/l/dsh-mcp-workspace-scope)](LICENSE)

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

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

![输入框里的 MCP 作用域药丸，以及每台服务器的会话级开关](docs/screenshot.png)

## 功能要点

- 按目录给 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，那是另一条路。
- **子智能体是独立判定的**：按它自己的工作目录算，而不是继承父会话的限制
  （见[工作原理](#工作原理)）。

## 安装

```bash
dsh plugin --profile web add dsh-mcp-workspace-scope
```

不想走 npm 的话，直接从源码装：

```bash
dsh plugin --profile web add github:felix-lj-ct/dsh-mcp-workspace-scope
```

然后重启 profile —— 正在跑的实例内存里还是旧代码：

```bash
dsh --profile web
```

`cordis.patch.yml` 的 bundle 层会自动挂载宿主半区，不需要手改 profile 配置。装完也不会
立刻改变什么：没有规则文件时，所有会话照旧注入全部 MCP（见下）。

## 规则文件

默认路径 `~/.dsh/mcp-workspace-scope.json`（`$DSH_HOME` 生效时跟着走）。
**文件不存在 = 插件不生效**，所有会话照旧注入全部 MCP —— 装上插件不会改变任何现状。

```json
{
  "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 页下方）

![规则编辑器：一条默认规则，一个目录整个禁掉，一个目录自定义勾选](docs/settings.png)

- 顶部显示规则文件路径、失败策略（放行/拦下）、是否拦调用；文件不存在时给出提示。
- **默认（未匹配任何规则的目录）**：全部 / 无 / 自定义三档，自定义时勾选服务器。
- **目录规则**：每条一行，目录可直接编辑；服务器选择器里列出 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 服务器的命令行、参数或环境变量。

## 开发

```bash
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
