Bundle
dsh-mcp-workspace-scope
Per-workspace MCP scoping for the DeepSeek Harness — decide which MCP servers a session may see from the directory it was opened in.
- Source
- felix-lj-ct
- stars
- 2 stars
- License
- MIT
- Updated
- Updated 12 hours ago
Readme
# dsh-mcp-workspace-scope
English | [简体中文](README.zh.md)
[](https://dsh-plugin.org/plugins/felix-lj-ct/dsh-mcp-workspace-scope)
[](https://www.npmjs.com/package/dsh-mcp-workspace-scope)
[](LICENSE)
**Give every project only the MCP servers it actually needs — and flip one on for a single
session when it doesn't.**
A DeepSeek Harness plugin that scopes MCP tool injection by the directory a session was
opened in, with per-session switches in the composer for the exceptions.

## Features
- Per-directory allow-list of MCP servers, inherited by subdirectories
- Hides the tools **and** refuses the calls — context savings plus a hard boundary
- Per-session switches right in the composer: narrow or widen the session you are in,
temporarily, without touching the rules
- Honest readout: each server's runtime state, so "allowed but dead" is visible
- Settings-page editor; a saved rule reaches running sessions immediately
## Why
A profile accumulates MCP servers. Every one of them ships its whole tool list into
**every** session, because MCP is process-global in DSH: `@deepseek-ai/dsh-mcp-client`
registers on the root `ctx.tools` as `mcp__<serverName>__<toolName>`, so a session that
will only ever touch Jira still carries three database servers and a browser driver in
its context window — and can call them by mistake.
This plugin narrows that per directory. A session opened in `D:\work\proj-a` gets only
`atlassian`, one opened in `D:\work\proj-b` gets only `playwright`, and every other
folder is left exactly as it was. When the exception comes up — "I need bigquery for the
next ten minutes" — the composer pill is a switch, for that session only.
## Limits
- **Cannot conjure a disabled server.** Anything in an allow-list must already be enabled
in the profile (e.g. via `dsh-skill-mcp-panel`). This plugin only subtracts.
- **Saves no processes:** a hidden server still runs and still holds its memory. For
"don't even start it", move the MCP rows into an agent preset instead — a different
approach.
- **Subagents are scoped independently**, from their own working directory rather than by
inheriting the parent's restriction (see [How it works](#how-it-works)).
## Install
```bash
dsh plugin --profile web add dsh-mcp-workspace-scope
```
Or straight from source, if you prefer not to go through npm:
```bash
dsh plugin --profile web add github:felix-lj-ct/dsh-mcp-workspace-scope
```
Then restart the profile — a running instance keeps the old code in memory:
```bash
dsh --profile web
```
The bundle layer in `cordis.patch.yml` mounts the host half; no manual profile edit. And
nothing changes yet: with no rules document, every session still injects every MCP server
(see below).
## Rules document
Default path: `~/.dsh/mcp-workspace-scope.json` (follows `$DSH_HOME` when set).
**No file = no effect** — every session keeps injecting all MCP servers, so installing
the plugin changes nothing until you write rules.
```json
{
"default": "*",
"rules": [
{
"path": "D:/work/master-data-management",
"servers": ["atlassian", "bigquery"]
},
{
"path": "D:/work/frontend",
"servers": ["playwright", "context7"]
},
{
"path": "D:/scratch",
"servers": []
}
]
}
```
| Field | Value | Meaning |
| --- | --- | --- |
| `default` | `"*"` | Unmatched folders inject everything (the default, and a no-op) |
| `default` | `[]` | Unmatched folders inject no MCP server at all |
| `default` | `["a","b"]` | Unmatched folders inject only those |
| `rules[].path` | directory | `~/` and `$DSH_HOME` expand; either slash works; case-insensitive on Windows |
| `rules[].servers` | as `default` | Allow-list for that directory and everything under it |
Matching:
- Subdirectories **inherit** their parent rule; comparison happens at a separator
boundary, so `/ws/proj` never matches `/ws/project`.
- **Longest path wins** — set a baseline on `/ws` and override it on `/ws/proj`.
- Among equal-length duplicates the **later row wins**.
- A session with no cwd falls back to `default`.
- A rules change applies to **running sessions immediately** (saving from the settings
page, or editing the file directly, both trigger it). This is deliberate: a workspace
has one reusable blank session and the harness declines to open a second one while it
is unused, so "add the workspace, set its scope, start working" needs the rule to reach
the session you are looking at. The harness already behaves this way — disabling a
server on the MCP page unregisters its tools from every live session through HMR. Set
`applyToRunningSessions` to `false` for the old freeze-at-creation behaviour.
## Plugin config (optional)
Only needed to relocate the document or change the failure policy. Goes under `config:`
on this plugin's row in the profile's `cordis.patch.yml`:
| Key | Default | Meaning |
| --- | --- | --- |
| `rulesPath` | `""` | Rules document path; empty = `<DSH home>/mcp-workspace-scope.json` |
| `enforceGuard` | `true` | Also refuse the call, not just hide the tool. Keep this on (see below) |
| `onRulesError` | `"open"` | Unreadable/malformed document: `open` = inject everything (as if absent), `closed` = inject nothing |
| `applyToRunningSessions` | `true` | Re-scope running sessions when the rules change; `false` freezes each session at creation |
| `logDecisions` | `true` | One log line per session: which rule matched, which servers survived |
## How it works
```
session created in some directory
↓ agent/created
read session.header.cwd → longest-prefix rule match → allow-list
↓
agent.ctx.tools.restrict({ deny: [...mcp__* outside the allow-list] }) ← hide
agent.ctx.tools.guard(...) ← refuse calls
↓ tools/change (server connected, reconnected, unloaded)
recompute the deny set and reapply
```
The two mechanisms are not redundant — they fire at different times:
- `restrict()` **must** be called on an agent-scoped context (the runtime rejects a
root-context call by design: it would mask every agent), and it validates every name
against the tools that scope currently inherits — so a deny set **cannot be written
ahead of a server that has not connected yet**. Visibility keeps up by recomputing on
`tools/change`.
- `guard()` is evaluated at call time and validates nothing up front, so it is immune to
the "registered a moment ago, called immediately" gap.
One known boundary: **subagents do not inherit the parent's restriction.**
`agentPresets.composeFrom()` binds a child agent's scope parent to the preset's standing
scope rather than to the parent agent, so a parent's `restrict()` never reaches it. This
plugin evaluates subagents independently from their own `session.header.cwd` (normally
inherited from the parent, so the outcome matches).
## UI
Installed, the plugin shows up in two places:
**1. Settings → "MCP scope"** (right under the MCP page)

- Header: rules-document path, failure policy, whether calls are refused; says so when
the document does not exist yet.
- **Default (directories matching no rule)**: All / None / Custom, with a server picker
for Custom.
- **Directory rules**: one editable row each; the picker lists every MCP server in the
profile with its live tool count and marks disabled ones (still selectable — they just
yield no tools).
- Add a rule from the known-workspace dropdown, or type a path by hand.
- Saving writes the document atomically host-side; a rejected payload shows the reason
verbatim and leaves the file untouched.
- Saving re-scopes running sessions immediately (with `applyToRunningSessions: false` a
new session is needed, and the badge then marks the session as frozen and names what
the current rules would give).
**2. An MCP badge in the composer tool row**
Rendered **always**, matched rule or not — deliberately: a capability readout that
disappears when nothing is configured cannot tell you whether scoping is in effect.
- no rule matched → `MCP all`
- rule matched → `MCP atlassian` (or `atlassian +1` for several)
- rule with `[]` → `MCP none` (amber)
The popover shows the session directory, which rule decided it, **each server's runtime
state**, and the visible/hidden tool counts — measured from that session's own agent
scope, not predicted from the rules (a session with no running agent is labelled as a
prediction).
### Changing one session's scope from the popover
Every server row in that popover carries a **toggle switch**, and the whole row is the hit
area — flip one to add or remove that server for the session you are looking at, or use
**All** / **None** / **Back to the rule**. The response to the write is the new readout,
so what is drawn is always what the host installed.
- **Temporary and in-memory.** Nothing is written to the rules document, and the override
dies with the agent — a new session (and the host after a restart) is back on the rules.
- **It outranks the rules while set.** A rules save no longer disturbs that session, so a
switch you flipped a moment ago is not silently undone. "Back to the rule" re-joins.
- **It may widen, not just narrow** — up to whatever the profile has enabled. The case
this exists for is "I need bigquery for the next ten minutes", which a narrow-only
control cannot serve. It still cannot conjure a disabled server (`restrict()` only
subtracts, so the enabled set is a hard ceiling).
- The badge turns **blue with a `*`** while a session is overridden: not a warning, but
the settings page no longer describes this session.
A session with no running agent has no scope to restrict, so the rows stay inert there
and the host refuses the write (`400`) rather than reporting a scope the model never got.
### "Allowed but unusable"
A rule may name four servers with two of them disabled in the profile: the scope looks
right and the session still cannot do the work. So every server carries a state dot (the
join is borrowed from `dsh-mcp-live-status`, same author, MIT):
| State | Meaning |
| --- | --- |
| connected | mounted and registering tools — the only genuinely usable state |
| up, not connected | fiber is ACTIVE but no tool registered (handshake never completed) |
| starting / mount failed / not mounted / disabled | the remaining cases |
Why the tool evidence is required: `dsh-mcp-client` defaults to
`failOnStartupError: false`, so **a server whose transport never connected still reaches
fiber state ACTIVE** — mount phase alone cannot tell "connected" from "up but dead",
while mcp-client registers nothing until connect() and listTools() both succeed.
The badge therefore turns amber with a `•` when an allowed server cannot serve anything,
and red with a `!` when a rule names a server this profile does not have (a typo, or a
removed server).
This also fixed a quiet attribution bug: `serverName` may contain underscores, so `foo`
and `foo__bar` can coexist and `mcp__foo__bar__baz` is legal for either — splitting at
the first `__` hands it to `foo` and mis-scopes it in both directions. Ownership now goes
to the longest matching name (with a test to keep it that way).
Note how this differs from `dsh-mcp-live-status`: that plugin reads the **global** view
(which server processes are connected), so it always lists every enabled server; this one
layers "what may this session use" on top. They measure different things, do not conflict,
and this plugin does not depend on it.
## Permissions and risk
This plugin **takes capabilities away** from a session; it can never add one. Everything
it grants must already be enabled in the profile, and managing servers stays with the
settings page — there is no way here to start, stop or reconfigure one.
| Surface | What it does |
|---|---|
| `ctx.tools` | Reads registered tool *names*; installs a per-agent `restrict()` + `guard()`. Never calls a tool. |
| `ctx.loader` | Reads the configured plugin tree (read-only iteration) to list MCP servers |
| `ctx.reflect` | Optional reads of `sessions` and `workspaceRegistry` — session cwd and known workspace paths, for the readout and the path picker |
| `ctx.webServer` | Three local JSON routes under `/dsh-mcp-workspace-scope`: read state, read one session's scope, write rules or a session override |
| Network | None outbound. The browser half fetches only those local routes. |
| Storage | One file: the rules document (`~/.dsh/mcp-workspace-scope.json` by default), written atomically, only when you press Save. |
**The failure mode to know about** is a rule that is stricter than you meant: the session
quietly has fewer tools, and the model will say it cannot do the thing rather than that it
is not allowed to. That is what the composer pill exists for — it states what the session
actually got, measured from the agent's own view. A broken rules document fails **open**
by default (`onRulesError`), so a typo cannot strip a working session; set it to `closed`
if you would rather it deny everything.
**No secrets are read.** The plugin only ever handles server *names* and tool *names* —
never an MCP server's command line, arguments, or environment.
## Development
```bash
npm install
npm run build # tsc → dist/ (committed; see .gitignore for why)
npm test # 24 smoke cases against a fake harness — no DSH required
```
The smoke test reproduces the three `ToolRuntime` behaviours this plugin leans on (the
global view ignores scoped restrictions, `restrict()` throws on unknown names, and both
`restrict()` and its disposer emit `tools/change`). Getting any of them wrong fails
silently in production. It also fakes `webServer`, so the JSON routes — including the
per-session override — are driven end to end, and one case runs headless to prove the
gating never depends on them.
## License
MIT
Install
dsh plugin --profile web add github:felix-lj-ct/dsh-mcp-workspace-scope
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-mcp-workspace-scope from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.