DeepSeek Harness Plugin

hyzyn/dsh-plugin-kit#mcp

Stars ★ 58 Downloads (30d) 4,559 Category Tools & Capabilities Added 2026-09-13 npm @hyzyn/dsh-mcp

MCP server configuration card for the DSH Web GUI: add / edit / enable / disable MCP servers over stdio (local subprocess) or streamable-http (remote), with js: prefixed expressions for env / header values, status badges, and hot-reload on save.

Install

# from npm (prebuilt)

dsh plugin --profile web add @hyzyn/dsh-mcp

# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)

dsh plugin --profile web add github:hyzyn/dsh-plugin-kit#path:/packages/mcp

Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).

README

中文 | English

DSH's “MCP Server Configuration” card: maintain MCP servers graphically, saving hot-reloads them, no host restart needed.

Features

  • Hot-loaded on save: rewrites the managed block of ~/.dsh/cordis.patch.yml; DSH’s HMR watcher reloads it automatically, and tools are registered to the model as mcp__<server>__<tool>.
  • stdio and streamable-http transports: stdio (command / args / env / cwd) and streamable-http (url / headers, including SSE and session headers); values can be written as js: expressions, so secrets never land in the patch file.
  • Connection Test without the MCP SDK: the host speaks JSON-RPC directly (initialize → tools/list), returning protocol version / serverInfo / tool list / elapsed time. The probe also has a verdict for errors: a 401/403 on POST or GET is reported at once as 认证失败(HTTP 401) (with WWW-Authenticate), other 4xx/5xx carry their status code — an auth failure is no longer swept into “SSE stream ended”; when a js: header or a stdio env value evaluates to undefined (including the shape hidden inside Basic … base64) it names the missing environment variables before sending any request or spawning the process.
  • Liveness and name conflicts are visible: reads liveness from the loader fiber (running / disabled / error / loading). Name clashes come in two distinct tiers, never conflated (issue #5): conflicts holds only rows that actually reuse an external serverName — both instances would claim the same mcp__<serverName>__* tool names, so the warning banner appears only then; the inventory of instances outside this card travels in externalServers and is shown as a neutral info banner (“these names are already taken elsewhere — don’t reuse them here”). Servers can be enabled / disabled (disabled: true) / edited / deleted. “Mounted” is not the same as “connected”: fiber liveness comes from the loader, while a server that cannot be reached still resolves its mcp-client apply (failOnStartupError defaults to false, so nothing throws) — an unreachable address therefore keeps showing a green “running”. Each managed row now carries a toolCount (the number of mcp__<serverName>__* tools in the registry): when the row is active and toolCount === 0, the badge reads “Not connected” instead (overriding the green “running”), and a line underneath spells out the cost (every boot waits once for that connection attempt) with a “disable” button right there. When the tool registry cannot be read, toolCount is absent (not 0) and the UI draws no conclusion — “cannot read” must never be rendered as “not connected”.
  • Capability notice injected into systemPrompt: injects a capability notice (systemPrompt section) so that when “MCP config / MCP server” comes up, the model knows it refers to this plugin.

Where the settings surface lives depends on the DSH version, but it is always the same form: from 0.2.0-rc.1 it is registered as plugins.bundle.config — inline on the plugin detail page, directly below the description, with no extra ">" step; older hosts (the 0.1.6 line) fall back to the row's ">" sub-page under the Plugins sidebar, and ≤0.1.5 uses the settings-page card.

Permission tiers and agent visibility

One visibility rule, acting on the agent tool surface only: the session tier answers “can this tier see it”.

  • Restricted tiers cut every mcp__*: in apply the plugin hooks the mcp__ prefix into the same tool-surface cut that docker / tty use (the kit’s attachTierRestrict, via ctx.inject(['tools'])). Under read-only / workspace-write (not Auto review, and with the tier service composed by the host) the agent’s tool table contains no mcp__* at all; danger-full-access is not cut; a host that has not composed the tier service cuts nothing either (same rule as docker / tty).
  • Everything is classified exec (the worst class): whether a given MCP tool reads or writes cannot be decided by this repo (that is each server’s own semantics), so it errs strict — whenever a tier cuts, every mcp__* goes on the deny list. What gets cut is the tool surface (a restricted session simply does not see them; it is not a per-call refusal) and this plugin does not attach the per-call gate (attachTierGate); the surface is recomputed on agent creation / tool-set changes and on a mid-session tier switch (since 2026-10-25 kit subscribes to the three knob events on session/event; upgrading brings the mcp__* tools back, downgrading drops them again).
  • Cost is visible: each row shows how many tools it currently contributes (the DTO’s toolCount; an absent field reads “unknown” — that is an unreadable registry, not 0), and once the known total passes 80 (AGENT_TOOL_BUDGET) a one-line hint sits above the list (disabled rows and rows without a readable count are left out of the total) — every added server costs that much resident tool surface, so disable the rows you do not need (a disabled row registers no tools).
  • Add up the bill before adding a server: the repo-root pnpm mcp-budget (scripts/mcp-tool-budget.mjs) sends one read-only GET to the running host and reports each server’s registered mcp__<server>__* tool count plus the total; --budget N exits 1 when exceeded, --json is machine-readable. It reads the global registry, orthogonal to session tiers (a tier-cut tool is still registered); servers mounted in the per-agent scope (codegraph) are not counted — a reported 0 means “not in the global layer”, not “no tools”; full semantics live in the script header.
  • The prefix cut only covers the inherited layer: the restriction is installed on the agent scope and covers the layer inherited from the plugin layer. Codegraph’s per-agent mounts live on the agent’s own layer (they are not in the global schemas() view), so they are not affected by this cut.

The criteria and the runtime evidence are in docs/permission-tier-plan.md, under the “mcp__*” item.

On-demand tool surface (agentToolLoading)

Tools are still registered by the host (no patch change, no process-model change, no card change); only the model-facing layer is dropped. The agentToolLoading option has two settings and defaults to eager:

Mode mcp__* the model can see
eager (default) Everything. Exactly as before this feature existed: no own tools registered, not a single extra restrict()
on-demand None by default: one restriction is installed when an agent opens a session (deny = every registered mcp__*), and the model loads what it needs through two own tools

The two tools under on-demand (fixed names; the model calls them exactly like this):

  • mcp_servers (read, no parameters): lists the configured servers — server / transport / state (enabled / disabled) / tools / loaded. tools: null means the tool registry cannot be read, not 0; an empty configuration and an “all unloaded” state each carry a note pointing at the next step.
  • mcp_load (exec, { servers: string[] }): loads the named servers and returns loaded (what was actually installed) / tools (the mcp__* names in that agent's view, computed on the spot) / unknown (names it does not recognise — not loaded, not thrown; the note lists the available ones). servers: [] unloads everything. Without a session context it answers only “this tool needs a session context” and loads nothing.

Four boundaries:

  • Only servers from this plugin's managed block are cut: rows outside that block (hand-written, or owned by another plugin) keep their tools untouched — we do not know their read/write semantics and have no way to load them, so hiding them would silently remove functionality from the user (mcp_servers likewise reports managed rows only).
  • A new server stays “unloaded” by default: a server that connects later, or a tools/change caused by a plugin hot reload, recomputes deny from the new registered set, so tools that appear later on an already-loaded server become visible with it.
  • The tier rule is unchanged: a restricted tier still has every mcp__* cut by attachTierRestrict, and the two restrictions intersect — on-demand opens no back door, and a restricted tier does not pass just because something was “loaded”.
  • Clean teardown: agent/disposed and plugin unload remove that agent's restriction and clear the ledger.

Why it is worth it: one real measurement

On 2026-10-25 we measured one real session's request/header event snapshot on this machine (that is the verbatim tools array of the request body): 149 tools = 130,439 B, of which MCP's 70 tools = 66,362 B (≈50.9%), ≈948 B per tool, ≈17k tokens per request after a CJK-aware conversion — and the agent loop resends all of it on every step.

on-demand drops that from the model view, so the request body no longer carries those 66 KB — the token saving is equivalent to “never registering” them. Cache hits only affect billing, not the context window, so the saving holds for the window too.

This is a single measurement from one local session, not a promise: tool counts follow the servers you configure, and the token conversion follows the tokenizer. Its purpose is an order of magnitude, not a price quote.

Why not per-agent process mounting

That is codegraph's isolation route: one MCP child process per session (≈40 MB), which buys “each session only sees its own context” at the cost of changing the patch row format and the card. It solves process and context isolation; this round solves the token bill, not process isolation — tools are still registered by the host, and only the model-facing layer is dropped. The two do not replace each other: real isolation still needs per-agent mounting, and on-demand loading still pays off on that route too.

Configuration and rollback

Override it by id in the profile patch (an install-level knob, separate from the server list on the card):

- id: mcp-config
  config:
    agentToolLoading: on-demand

Rollback has zero side effects: delete that line (or write eager back) and restart the host — the tool surface is exactly what it is today. The eager branch registers no own tools and sends no extra restrict(), so there is no resident state to clean up.

What is still missing before on-demand becomes the default

One real model turn's acceptance: with mcp_servers / mcp_load in view, the model first calls mcp_servers, then mcp_load({ servers: […] }) to assemble the tools it needs, instead of detouring through bash to read config / run a CLI. Only a real model can verify that (the script proves the surface and the load action are correct; it cannot prove the model's choice). Until then the default stays eager: changing the default re-routes every session that does not set this option, and the rollback cost is asymmetric.

Real-machine regression: scripts/verify-mcp-on-demand.mjs (in-process real host, no host spawn, isolated temporary DSH_HOME, and it asserts the real patch is byte-identical on exit).

Structure

File Description
src/index.ts Host half: managed block read/write, validation, status reading, JSON-RPC probing, /api/dsh-mcp/* routes (loopback-only fence)
client.js Browser half: registers the settings.plugin.item card (React shell + pure-DOM admin panel, window.__ModuleLoader__.load format)
cordis.patch.yml Bundle patch: inserts the plugin row into the profile lineup

Routes (loopback + same-origin only):

  • GET /api/dsh-mcp/servers — list + status (including each row’s toolCount, see below) + real clashes (conflicts) / instances outside this card (externalServers). A failed DTO read returns 500 with the original reason: the host webserver answers a throwing handler with an empty 400, which shows nothing in the UI (the fence has already passed, so returning the text is safe and makes the next incident obvious at a glance)
  • POST /api/dsh-mcp/servers/save — save the whole set (after validation, write back to the managed block)
  • POST /api/dsh-mcp/test — run one Connection Test with the form configuration

Fence (same tier as docker / tty since 2026-09-25): the loopback fence uses isLoopbackRequestStrict from @hyzyn/dsh-kit (the whole 127/8 range plus DNS confirmation for alias hostnames), and the two write routes above additionally require a same-origin proof (Sec-Fetch-Site: same-origin or a matching Origin; when both are absent, only requests carrying the host session cookie pass — that is the desktop shell's forwarding chain, see docker D139). The read-only GET /servers needs no proof, so bare curl still works. The rationale and the single implementation live in packages/kit/src/http.ts.

Installation

pnpm --filter @hyzyn/dsh-mcp build
dsh plugin --profile web add link:$(pwd)/packages/mcp

dsh plugin add installs this package into the profile dependencies and, because it declares dsh.bundle, also adds it to the dsh.profile.bundles patch layer — which is what makes the insert: { id: mcp-config, name: '@hyzyn/dsh-mcp' } plugin row in cordis.patch.yml take effect, so you only mount it once. After the plugin code is updated, restart dsh web for it to take effect.

⚠️ Do not manually append the same plugin row to ~/.dsh/cordis.patch.yml again: inserting the same id once in each of two patch layers triggers duplicate loader entry id: mcp-config at startup, and the host process exits immediately. The home patch layer should only keep the plugin’s own # --- dsh-mcp-config managed ... managed block (server rows, not the plugin row).

The plugin row itself needs no HMR: after you save the MCP Server Configuration (the rows inside the managed block), DSH’s watcher on the home patch layer hot-loads them as mcp__<server>__<tool> tools within 1–2 seconds. After refreshing the page, manage servers in the Web GUI’s “MCP Server Configuration” card. Note: the browser half depends on the core slots service, and only the official settings panel of dsh-web-app provides that slot.

Uninstallation

dsh plugin --profile web remove @hyzyn/dsh-mcp

After restarting dsh web, the plugin row disappears with it. The managed block can stay (just empty lines when no plugin is present), or you can first clear the server list in the panel and then manually delete the # --- dsh-mcp-config managed ... block.

Notes

  • The managed block is marked with # --- dsh-mcp-config managed (auto-generated; do not edit) ---; the plugin only rewrites that block and keeps everything else as is; the configuration inside the block aligns with the official mcp-client Config schema. An empty list is written as - insert: [] (a no-op for the entry tree) — be careful not to write a bare [], which would break the top-level YAML document of the home patch file, making HMR config refresh fail to parse and deleted servers unable to unload
  • The save endpoint validates serverName ([A-Za-z0-9_-]{1,32}), required transport fields, and reconnect parameter bounds
  • An empty list needs explicit confirmation to clear: /servers/save replaces the whole table, so servers: [] means clearing the managed block. To stop a startup race or a stale card from silently wiping the servers you already configured (measured: the file was left with nothing but - insert: [], and there was no way to tell afterwards who cleared it), the host answers 400 for a save that is "an empty list without clearAll: true" and says in the message how many entries would be removed; the card only sets that flag when the user explicitly deletes the last row (the delete confirmation says so too). A block that was already empty (no entries to begin with) is not destructive and is allowed through — saving an empty card does not error
  • The !!js expressions of a Connection Test are evaluated inside the host (the same trust model as the loader)
  • The criterion for “mounted but not connected” is a tool count, not the fiber state: tools are the only artefact “a successful connection + tools/list” leaves behind, so counting them yields the real connection state for free (a walk over the in-memory tool registry, with no network probing). Attribution is by the mcp__<serverName>__ prefix, using the same character rules as publicName() in the core @deepseek-ai/dsh-mcp-client; serverName is capped at 32 characters, so the prefix always stays inside the core’s 51-character truncation line and the attribution is reliable — should an over-long prefix ever appear, that row is skipped rather than guessed. Rows with disabled should have no tools anyway and do not count as “not connected”. Note this does not change startup cost: an unreachable address still costs one connection attempt per boot (about 10s; up to 60s when reachable but unresponsive) — disable the row to skip it
  • The browser half is hand-written ESM (the React shell is resolved through __ModuleLoader__’s require, and the panel itself is pure DOM), so no tsdown is needed; the host half emits ESM straight from tsc

Troubleshooting: triaging server is disconnected

The symptom looks the same, but there are four different causes, and the fixes are nothing alike. Do not start by guessing at credentials — locate the row below first, then act (in one real incident on 2026-10-24 three causes coexisted: missing injection + no VPN + exhausted reconnect budget; each layer on its own looked like "authentication failure", which is what made it expensive to diagnose).

Criterion (one pasteable command / piece of evidence) Conclusion Fix
nc -z <host> <port> fails (for an intranet address, first check whether ping is 100% packet loss) Network unreachable — nothing to do with the plugin or the credentials Connect the VPN / move to a network that can reach that host
The port is open, but the host log ends with giving up after N consecutive failed reconnect attempts Reconnect budget exhausted: once reconnect.maxAttempts, which defaults to 10 (RECONNECT_DEFAULT_MAX_ATTEMPTS), is used up, the client gives up and unregisters that server's tools — it will not retry later even after the network recovers Restart the host (or HMR-reload the plugin)
Port open, no give-up record in the log, still not connected The connection is still being established, or just failed and is awaiting a retry (about 10s for a first attempt; up to 60s when reachable but unresponsive) Wait one reconnect cycle; if it keeps failing, treat it as the row above
401 / tool count is 0 Credentials not injected: !!js process.env.X evaluates to undefined and builds Basic base64("undefined:undefined") — a legal but wrong string, so the server can only answer 401 Install and enable @hyzyn/dsh-env, and confirm those keys really reached the host process (env provides dshEnvReady only after its synchronous pre-injection)

The other two reconnect defaults work the same way — the backoff doubles from the initial value and is capped:

  • reconnect.initialDelayMs defaults to 500 ms (RECONNECT_DEFAULT_INITIAL_DELAY_MS);
  • reconnect.maxDelayMs defaults to 30000 ms (RECONNECT_DEFAULT_MAX_DELAY_MS), which is also the backoff ceiling.
  • All three numbers above are named constants: changing one breaks nothing at build time, but the numbers in this section are recomputed by scripts/check-readme-facts.mjs — come back and edit this section when you change them.
  • maxAttempts is a budget of consecutive failures: it resets once a connection has stayed up longer than maxDelayMs, so "it dropped once" does not mean "it gives up next time".

Three supporting criteria:

  • Whether the auth header is already correct: !!js is evaluated by the loader when it activates the mcp-client entry (its process is the host process), not by this plugin — this plugin's evalValue() only serves the Connection Test button. So "the card looks fine but the agent side cannot connect" is usually not this plugin's problem; work through the table above.
  • The Connection Test can produce the verdict for the credential row: it reports "the environment variable X referenced here has no value in the current host process (not injected)" instead of a vague 401. But that check only runs when you press Connection Test; it does not cover the connection made at startup.
  • A restart is the only way out once the reconnect budget is spent: enabled: true means "it will retry after a failure", not "it will retry forever".

Content from the project README on GitHub ↗

Links

More in this category

View the whole category →

Community comments

Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.