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 asmcp__<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)(withWWW-Authenticate), other 4xx/5xx carry their status code — an auth failure is no longer swept into “SSE stream ended”; when ajs:header or a stdioenvvalue evaluates toundefined(including the shape hidden insideBasic …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):
conflictsholds only rows that actually reuse an external serverName — both instances would claim the samemcp__<serverName>__*tool names, so the warning banner appears only then; the inventory of instances outside this card travels inexternalServersand 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 itsmcp-clientapply (failOnStartupErrordefaults tofalse, so nothing throws) — an unreachable address therefore keeps showing a green “running”. Each managed row now carries atoolCount(the number ofmcp__<serverName>__*tools in the registry): when the row isactiveandtoolCount === 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,toolCountis 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__*: inapplythe plugin hooks themcp__prefix into the same tool-surface cut thatdocker/ttyuse (the kit’sattachTierRestrict, viactx.inject(['tools'])). Underread-only/workspace-write(not Auto review, and with the tier service composed by the host) the agent’s tool table contains nomcp__*at all;danger-full-accessis not cut; a host that has not composed the tier service cuts nothing either (same rule asdocker/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, everymcp__*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 onsession/event; upgrading brings themcp__*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 registeredmcp__<server>__*tool count plus the total;--budget Nexits 1 when exceeded,--jsonis 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 reported0means “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: nullmeans the tool registry cannot be read, not 0; an empty configuration and an “all unloaded” state each carry anotepointing at the next step.mcp_load(exec,{ servers: string[] }): loads the named servers and returnsloaded(what was actually installed) /tools(themcp__*names in that agent's view, computed on the spot) /unknown(names it does not recognise — not loaded, not thrown; thenotelists 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_serverslikewise reports managed rows only). - A new server stays “unloaded” by default: a server that connects later, or a
tools/changecaused by a plugin hot reload, recomputesdenyfrom 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 byattachTierRestrict, and the two restrictions intersect —on-demandopens no back door, and a restricted tier does not pass just because something was “loaded”. - Clean teardown:
agent/disposedand 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’stoolCount, 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.ymlagain: inserting the sameidonce in each of two patch layers triggersduplicate loader entry id: mcp-configat 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/savereplaces the whole table, soservers: []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 withoutclearAll: 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
!!jsexpressions 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 themcp__<serverName>__prefix, using the same character rules aspublicName()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 withdisabledshould 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__’srequire, 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.initialDelayMsdefaults to 500 ms (RECONNECT_DEFAULT_INITIAL_DELAY_MS);reconnect.maxDelayMsdefaults 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. maxAttemptsis a budget of consecutive failures: it resets once a connection has stayed up longer thanmaxDelayMs, so "it dropped once" does not mean "it gives up next time".
Three supporting criteria:
- Whether the auth header is already correct:
!!jsis evaluated by the loader when it activates themcp-cliententry (itsprocessis the host process), not by this plugin — this plugin'sevalValue()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: truemeans "it will retry after a failure", not "it will retry forever".
Links
More in this category
Tencent/WeKnora#dsh-weknora★ 35688
Four read-only tools over a WeKnora knowledge base: list knowledge bases, hybrid passage search, reassemble one document's chunks in order, and WeKnora's own cited RAG or ReAct-agent answer with a resumable session id.
superdesigndev/treg★ 6403
Tool catalog for agents: search ~2,600 external endpoints (SEO and SERP, backlinks, social, people and company enrichment, ad libraries, scraping) by the task you want done, read each one's parameters and per-call price, then call it with the credential injected server-side. Ships the skill plus an MCP row that stays disabled until TREG_TOKEN is set.
T-Auto/dsh-ops★ 4593
Provides bash, PowerShell 7, and high-performance Rust tools for DSH on Windows to reduce token usage.
sixtysevenlf/dsh-blender-plugin★ 1388
Lets a DSH agent drive a local Blender over a direct TCP channel with 15 tools: viewport frames, custom-angle renders, Python execution, headless and job offload, transaction rollback, and a planning layer of 228 ops across 34 families.
TencentCloudBase/CloudBase-AI-Toolkit#dsh-plugin★ 1152
Tencent CloudBase backend for DeepSeek Harness — scaffold and deploy full-stack apps from chat, render query results as table cards with paging, sorting and CSV export, preview a deployment on its domain, and call the CloudBase MCP toolset (`mcp__cloudbase__*`) with device-code login.
anysearch-team/anysearch-dsh★ 665
AnySearch-powered real-time web and vertical search provider for DeepSeek Harness.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.