dsh-mcp-workspace-scope
Scopes MCP tool injection per workspace directory in the DeepSeek Harness: a session opened in a project sees only the MCP servers that project needs — removed from the model's tool list and refused at call time. Plus per-session switches in the composer to narrow or widen the session you are in, temporarily.
- Stars
- 1
- Language
- JavaScript
- Created
- Aug 31, 2026
- Updated
- Aug 31, 2026
Introduction
dsh-mcp-workspace-scope
English | 简体中文
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).
Install
dsh plugin --profile web add dsh-mcp-workspace-scope
Or straight from source, if you prefer not to go through npm:
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:
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.
{
"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/projnever matches/ws/project. - Longest path wins — set a baseline on
/wsand 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
applyToRunningSessionstofalsefor 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 ontools/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: falsea 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(oratlassian +1for 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
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