reactive-resume
reactive-resume
A one-of-a-kind resume builder that keeps your privacy in mind. Completely secure, customizable, portable, open-source and free forever. Try it out today!
felix-lj-ct/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.
PROJECT TOPICS
INSTALL REFERENCE
dsh plugin --profile web add github:felix-lj-ct/dsh-mcp-workspace-scope
该命令指向仓库当前默认分支;尚无绑定当前 commit 的完整验证结果。
PROJECT README
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.

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.
dsh-skill-mcp-panel). This plugin only subtracts.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).
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:
/ws/proj never matches /ws/project./ws and override it on /ws/proj.default.applyToRunningSessions to false for the old freeze-at-creation behaviour.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 |
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).
Installed, the plugin shows up in two places:
1. Settings → "MCP scope" (right under the MCP page)

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.
MCP allMCP atlassian (or atlassian +1 for several)[] → 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).
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.
restrict() only
subtracts, so the enabled set is a hard ceiling).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.
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.
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.
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.
MIT
CLASSIFICATION EVIDENCE
系统优先读取 GitHub Topics,再与站内分类词典和词根规则比对。当前命中: mcp、model-context-protocol。