dsh-lazymcp
DiscoveredLazy MCP client for DeepSeek Harness: registers a server's tools from a cached manifest at boot and connects on the first call.
dsh-lazy-mcp
A lazy-loading MCP client plugin for DeepSeek Harness.
It is a client only. It ships no MCP server and bundles no server code: the processes it starts are the ones you configure, stdio or streamable HTTP, exactly as you would run them by hand.
The shipped @deepseek-ai/dsh-mcp-client connects to every configured server and discovers its tools during plugin activation. With several servers (or a large one such as godot-mcp) that makes every harness start wait for all of them, even when the session never uses those tools.
dsh-lazy-mcp keeps the same model-facing tool names (mcp__<server>__<tool>) but moves the connection to the first tool call:
| Startup | What happens |
|---|---|
| First ever start (no cache) | bootstrap: eager connects once, registers the tools and writes the manifest. background does the same without blocking boot. |
| Every later start | Reads the manifest from disk, registers the same tools/schemas, and starts no process and no socket. |
First mcp__… call | Connects, refreshes the manifest, swaps stale registrations, then forwards the call. |
| MCP resource request | Connects on demand; resources stay lazy as well. |
The same row can mount many servers. A hub row reads the
dsh-lazy-mcp registry and the MCP config files other clients already use
(.mcp.json, ~/.claude.json, ~/.cursor/mcp.json, …), then gives each
server it finds the same lazy treatment. See
One row, many servers.
Measured against godot-mcp (14 tools, ~5 KB of schemas):
- first run, connect +
tools/list+ cache write: ~180–220 ms - cached start, 14 tools registered, server not started: ~0.5 ms
- real DSH boot with a warm cache: no
[SERVER] Using Godot…line, i.e. the MCP process was never spawned
Install
Install it from the dshmarket catalog: open Plugins → Market, search for dsh-lazy-mcp, and click Install on the kvnis/dsh-lazy-mcp entry. The catalog also carries an unrelated plugin by the same name — installing both collides on the package name, so pick this repository's entry. The market pins the current commit, so the install is reproducible and later updates are offered in the same list.
Without the catalog — or before the entry is accepted — add the GitHub source directly:
dsh plugin --profile main add github:kvnis/dsh-lazy-mcp
Electron app (owns the desktop profile): Plugins → Add plugin → Install a third-party plugin, paste github:kvnis/dsh-lazy-mcp, install, then switch the bundle on if it is not already enabled. The CLI refuses dsh plugin --profile desktop … with “profile desktop is managed exclusively by the Electron application”.
For development, a checkout can be installed instead (git clone https://github.com/kvnis/dsh-lazy-mcp ~/GitHub/dsh-lazy-mcp && npm ci --omit=dev, then add ~/GitHub/dsh-lazy-mcp as the plugin). That produces a link: dependency, which the market lists but does not offer updates for.
The Plugins page lists profile bundles, not individual MCP rows: the package shows up under Installed plugins as dsh-lazy-mcp, and the example mcp-lazy-example row stays disabled until you enable it, so installing starts nothing.
The package declares a bundle patch with one disabled example row, so installing it does not start anything. Do not keep an existing @deepseek-ai/dsh-mcp-client row for the same serverName: both would register the same mcp__<server>__<tool> names. Remove or disable the eager row, then add a row to the active profile's
cordis.patch.yml (~/.dsh/profiles/desktop/cordis.patch.yml for the Electron
app, ~/.dsh/profiles/main/cordis.patch.yml for the CLI profile). Because a
patch replaces the whole config, include it. One row per server — and write it
as an insert, because this bundle ships only a disabled example row, not a row
named after your server:
- insert:
- id: mcp-fs-lazy
name: dsh-lazy-mcp
config:
serverName: fs # tool prefix: mcp__fs__<tool>
transport: stdio
command: /usr/bin/node # absolute path, not `node` — see below
args:
- /home/you/mcp/my-server/build/index.js
# cwd, env: whatever that server needs
bootstrap: eager
A bare - id: entry is an override, not an insert: it only reconfigures a row
some bundle already defines, and an id that matches nothing is warned about and
skipped — the usual way to end up with zero tools and no error. Wrap rows you
are adding in - insert:; the HTTP example below does the same.
That command/args pair is a server you already have — this plugin spawns it lazily and caches its schemas, nothing more. Any stdio MCP server works. For a streamable-HTTP server, use url/headers instead:
- insert:
- id: mcp-remote-lazy
name: dsh-lazy-mcp
config:
serverName: remote
transport: streamable-http
url: https://example.com/mcp
headers:
Authorization: !!js `Bearer ${process.env.REMOTE_MCP_TOKEN}`
Skip this per-server row if you use hub mode: one hub row can mount this server and every other one already declared in another client's config.
The row is spawned, not shell-expanded: command, cwd and every args entry
must be absolute paths — ~, $HOME and other variables are passed through
verbatim, and so are env values. A bare command: node resolves against the
PATH of whatever launched the harness, which a desktop launcher usually trims to
/usr/bin:/bin; use what command -v node prints.
env entries are merged over the scrubbed parent environment and are part of the
cache key, so editing them re-discovers the tool list on the next start.
One row, many servers (hub mode)
A row that names no server of its own becomes a hub: it mounts every MCP
server it can find, one lazy cache per server, and keeps the set current while
the harness runs. Tool names still come from each server, so they stay
mcp__<name>__<tool>.
The registry is the file this plugin owns — ~/.dsh/lazy-mcp/servers.json, or
$DSH_HOME/lazy-mcp/servers.json when DSH_HOME is set:
{
"servers": {
"fs": { "command": "/usr/bin/node", "args": ["/home/you/mcp/fs/build/index.js"] },
"remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer …" } },
"old": { "disabled": true, "command": "/usr/bin/node" }
}
}
{"mcpServers": {…}} and an array of entries carrying a name are accepted
too, so most existing MCP config files can be copied in unchanged. An entry
takes the connection fields a row takes (command, args, env, cwd,
url, headers); disabled: true or enabled: false skips it without
deleting the row.
By default the hub also reads config files other MCP clients already maintain:
| Source | What is read | Switch |
|---|---|---|
| Registry | ~/.dsh/lazy-mcp/servers.json | discoverRegistry |
| Workspace | .mcp.json, .cursor/mcp.json, .vscode/mcp.json | discoverProject |
| Claude Desktop | claude_desktop_config.json | discoverClaude |
| Claude Code | ~/.claude.json | discoverClaudeCode |
| Cursor | ~/.cursor/mcp.json | discoverCursor |
| VS Code | ~/.config/Code/User/mcp.json | discoverVscode |
- id: mcp-hub
disabled: false
config:
discovery: true # only needed when this row also names a server
bootstrap: eager # first run connects once per server, then caches
schemaMode: compact
skip: old-experiment # names that must never mount, comma-separated
discoverProject: false # do not trust per-repository files here
Discovery defaults to on for a row that names no server and off for one
that does (a legacy command/url row or one with servers[]), so an
existing single-server config never grows surprise mounts. Set
discovery: true to combine an explicit list with discovery.
Hub-level bootstrap, schemaMode, tools, escapeHatch, cache,
cacheDir, toolCallTimeoutMs, maxInstructionBytes and
failOnStartupError are inherited by every server the hub mounts; a
servers[] entry can override any of them as well as its connection fields.
An explicit servers[] entry wins over a discovered server of the same name;
skip wins over everything. The registry wins over the other files.
While running, the hub re-reads the sources every three seconds: a new server is
mounted, a changed command/args/env remounts it, and a removed or
disabled one has its tools unregistered. Caches are keyed by server identity, so
a remount with an unchanged key restores from cache without connecting, and a
server added later is cached on its first call. Removing an entry unregisters
its tools but leaves its cache file on disk.
Example: a large server
The numbers at the top were measured on the largest server this was tested
against — godot-mcp, 157 tools.
It is a normal third-party MCP server; nothing of it ships in this plugin, and
the row below is only a worked example of a heavy stdio server.
Prerequisites are that server's own: Godot 4.4 or later (godot --version) and
Node 18 or later. Build a checkout (the directory is yours; the row below assumes
~/mcp/godot-mcp):
# 157-tool fork — what this README measures
git clone https://github.com/tugcantopaloglu/godot-mcp.git ~/mcp/godot-mcp
# or the 14-tool upstream, instead:
# git clone https://github.com/Coding-Solo/godot-mcp.git ~/mcp/godot-mcp
cd ~/mcp/godot-mcp && npm install && npm run build
- id: mcp-godot-lazy # any id; this one is just the example's
disabled: false
config:
serverName: godot
transport: stdio
command: /usr/bin/node # absolute path, not `node`
args:
- /home/you/mcp/godot-mcp/build/index.js
cwd: /home/you/mcp/godot-mcp
env:
GODOT_PATH: /home/you/bin/godot
toolCallTimeoutMs: 90000
bootstrap: eager
GODOT_PATH is that server's own convention for finding the engine binary, not a
plugin setting. The first start connects once and writes
~/.dsh/lazy-mcp/godot.json; every start after that is lazy.
What success looks like
You brought a server; this section is how you tell the plugin is serving it rather than getting in the way. After a restart, all three should hold:
- Tools are listed. The tool panel shows
mcp__<serverName>__*— 157 of them in the example above, your server's own count otherwise. - The cache exists.
~/.dsh/lazy-mcp/<serverName>.jsonholds thetools/listresult, mode 0600. - Nothing started early. No process for that server was spawned at boot.
The first
mcp__<serverName>__…call starts it, and it stays connected.
With hub mode the same three checks apply per discovered server: one cache file per server keeps appearing as it is used, and none of them run until a tool from that server is called.
From there the tools behave like any MCP tools: whatever arguments the server expects is what you pass, and nothing about them is specific to this plugin. For a server that work is scoped by directory, naming the directory once is enough:
In /home/you/games/platformer, add a
Playernode tores://scenes/main.tscnand save the scene. Then run the project and show me the last 20 lines of output.
Configuration
| Field | Default | Meaning |
|---|---|---|
serverName | required | [A-Za-z0-9_-]{1,32}; prefixes every tool name |
transport | required | stdio or streamable-http |
command, args, env, cwd | — | stdio server; environment is the scrubbed parent env plus env |
url, headers | — | streamable-HTTP server |
cache | true | set false to disable the manifest and always bootstrap eagerly |
cacheFile | <cacheDir>/<serverName>.json | explicit manifest path |
cacheDir | $DSH_HOME/lazy-mcp | manifest directory |
bootstrap | eager | what to do when no manifest exists: eager, background, or off |
failOnStartupError | true | fail activation when the eager first-run bootstrap fails |
toolCallTimeoutMs | 60000 | per tools/call and resource request |
maxInstructionBytes | 32768 | reject oversized server instructions |
tools | all | allowlist of exact MCP tool names to expose; unlisted tools stay reachable through the escape hatch |
escapeHatch | true | with tools, also register mcp__<server>__call to invoke any tool by name |
schemaMode | full | compact strips schema titles, property descriptions, defaults, examples and bounds, and collapses descriptions to one line |
Hub mode adds these:
| Field | Default | Meaning |
|---|---|---|
servers | — | explicit hub list: [{name, command, args, …}] |
discovery | on when the row names no server | read the hub registry and the config files below |
discoverRegistry, discoverProject, discoverClaude, discoverCursor, discoverVscode, discoverClaudeCode | true | per-source switches, see Hub mode |
registryFile | $DSH_HOME/lazy-mcp/servers.json | hub registry path |
skip | — | comma-separated server names that must never mount |
A manifest is invalidated automatically when the server name, transport, command/args, cwd, URL, or the set of env/header names changes.
Token budget profiles
Every registered tool costs its name, description and input schema on each model request, whether or not it is called. A server with 157 tools can spend tens of thousands of tokens per turn doing nothing. Two independent levers shrink that:
toolsregisters only the listed tools. Point it at the handful a task actually needs.schemaMode: compactkeeps the same tools but trims the schema text roughly in half.
Measured against the 157-tool Godot server: the full profile registers 79,812
characters of name + description + input schema (~20k tokens); compact
registers 48,050 (~12k), a 40 % cut with the same 157 callable tools. The
allowlist is the bigger lever — an unregistered tool costs nothing at all.
- id: mcp-godot-lazy
disabled: false
config:
serverName: godot
transport: stdio
command: /usr/bin/node
args: [/home/you/mcp/godot-mcp/build/index.js]
cwd: /home/you/mcp/godot-mcp
tools:
- launch_editor
- run_project
- get_debug_output
- stop_project
- get_scene_tree
- game_screenshot
- game_get_logs
schemaMode: compact
escapeHatch: true
Unlisted tools stay reachable through one meta-tool,
mcp__godot__call, so a narrowed profile is not a dead end — the model can
still run any tool by name when the task turns out to need it:
{ "name": "game_set_camera", "arguments": { "position": { "x": 0, "y": 2, "z": 6 } } }
The escape hatch costs one small schema instead of one per tool. Set
escapeHatch: false for a hard allowlist. Tool names are matched exactly; get
the real list from ~/.dsh/lazy-mcp/<serverName>.json after a discovery, or from
the harness tool panel.
Because tools and schemaMode only change how cached tools are presented,
they do not invalidate the manifest: editing them costs a restart, not a
reconnect.
How it works
- Manifest cache. After a successful
tools/list, the raw tools (name, description, input schema, output schema) and server instructions are written atomically with mode0600. On the next activation these are registered as thin proxies. - Proxy execution. Each proxy waits for one shared connection attempt, then calls
tools/callon the real server. A closed transport clears the client, so the next call reconnects and re-syncs. - Live refresh. A
tools/list_changednotification schedules a serialized re-sync; registrations are added, replaced, or removed before the next model request. The manifest is rewritten. - Resources and instructions. Instructions are served from the cache until a connection refreshes them. The MCP resource provider connects only when a resource is requested.
- Hub reconcile. A hub row re-reads the registry and discovery files every three seconds and diffs the desired set against the mounted one: new servers mount, changed identities remount, removed ones unmount. The diff is serialized, and a malformed entry is logged and skipped without affecting its neighbours.
- Failure containment. A tool whose schema is rejected by the harness is skipped with a warning instead of failing the whole server. A failed cached-start call leaves the cached tools registered for the next attempt.
Troubleshooting
initial connection or tool discovery failed ... Connection closed on first enable almost always means the configured command/args do not start the server. Check that args[0] is the built build/index.js and that the path is absolute. The current version names a missing absolute entry directly; restart the harness (or toggle the component) after fixing the config.
Nothing appears and the log has no server line — check the row itself: the shipped example is disabled: true on purpose, and a row added to a profile other than the one running (main while the app runs desktop) never loads.
Tools vanish after upgrading the package — bundle row ids are owned by the package, and an upgrade may rename or drop the row an override was written against. For example, 0.2.0 shipped an mcp-godot-lazy row that 0.3.0 replaced with a disabled mcp-lazy-example row. An id-targeted override for an id no bundle defines anymore is warned about and skipped, so nothing mounts and no server starts. Rewrite the entry as an - insert: row (see Install) — that both fixes the override and survives the next id rename.
A tool returns nothing though the server works — arguments are passed to the server verbatim; this plugin does not normalize them. Server-specific quirks still apply, e.g. a Godot list_project_files extension filter needs dot-prefixed values ([".tscn"], not ["tscn"]); the wrong spelling returns an empty list rather than an error. Check the server's own docs when in doubt.
spawn ENOENT, or the server exits immediately — command is not on the app's PATH. Put in the absolute path that command -v node prints, and use absolute paths for args/cwd while you are there.
The first successful connection writes ~/.dsh/lazy-mcp/<serverName>.json. To force a fresh discovery, delete that file and restart. With DSH_LAZY_MCP_DEBUG=1, the plugin writes activation decisions to stderr.
Deliberate limits
- First run is eager by default. A lazy client cannot know tool schemas before connecting, so the manifest has to come from somewhere. Use
bootstrap: backgroundto avoid even that first-run wait, at the cost of tools appearing shortly after boot. - Cached schemas can be stale. They are refreshed on the first call or on
tools/list_changed; a tool removed upstream may fail once before the refresh lands. - No reconnect supervisor. Unlike the shipped client, this plugin does not keep retrying in the background. A dropped connection is retried by the next tool call.
- Image/audio MCP blocks become text markers. The shipped client stores them as durable attachments; this plugin's smaller adapter does not.
- Discovery is opt-in for explicit rows. A legacy
command/urlrow does not start reading~/.claude.jsonbehind your back; hub mode is the default only for a row that names no server. - Discovery files are metadata, not a transport. An entry that uses an unsupported transport or a missing
commandis skipped with a log line; env values andargsare passed through verbatim, never shell-expanded. - A hub polls instead of watching. Changes appear within three seconds, not instantly, and a
tools/list_changedpush still refreshes schemas on the usual path. - No per-server duplicate-name reservation across agent scopes. Registering two instances with the same
serverNamein one composition produces a tool-name conflict warning; use distinct names. - Task-based tool execution is rejected, matching the shipped bridge's limitation.
Verification
There is a dependency-free test suite:
npm test # node test/hub.test.mjs
It runs the real apply() path against a throwaway stdio MCP server
(test/fixtures/fake-server.mjs) and covers both shapes: single-server
allowlists and the escape hatch, schemaMode: compact, manifest writes, hub
discovery from the registry, cached restores without a process, first-call
connect/forward, live add/change/disable remounts, malformed entries, duplicate
filtering, and legacy-config compatibility. Each phase builds its own fake
ctx, so the suite also checks the tear-down path.
For a syntax/export check without running anything:
node --check index.js
node -e "import('./index.js').then(m => console.log(Object.keys(m)))"
# Config, apply, normalizeConfig, normalizeHubConfig, compactSchema, compactText
For a real DSH boot check, run the CLI with a profile that has the row configured:
# cold: connects once, writes the cache
DSH_LAZY_MCP_DEBUG=1 node apps/cli/lib/bin.js --profile main
# warm: logs "157 tools restored from cache" and no godot server output
DSH_LAZY_MCP_DEBUG=1 node apps/cli/lib/bin.js --profile main
DSH_LAZY_MCP_DEBUG=1 writes activation decisions to stderr; it does not change behavior.
Comments
Loading…
Similar plugins
by wings1848
Lazy MCP gateway for DeepSeek Harness: one stable proxy tool instead of N tool schemas, servers connect on first use and idle out, metadata cached to disk.
★ 11
MIT
TypeScript
Oct 28, 2026
dsh plugin --profile web add dsh-mcp-lazyby leaforbook
Lazy on-demand MCP bridge for DeepSeek Harness
★ 7
MIT
JavaScript
Aug 20, 2026
dsh plugin --profile web add @yilinxiao/dsh-mcp-lazyby zhe97396-tech
MCP gateway for dsh that connects each server on first use and disconnects it after an idle window; tool lists are cached on disk, existing mcpServers files can be imported, and child-process environm
★ 1
MIT
JavaScript
Oct 19, 2026
dsh plugin --profile web add dsh-lazy-mcpby MikotoMyWife
Lazy-loading MCP tools for DeepSeek Harness (DSH): one loader tool per multi-tool MCP server, per-agent tool masking, description presets
★ 0
MIT
TypeScript
Oct 8, 2026
dsh plugin --profile web add dsh-mcp-loaderby CoderKuo
Manage the MCP servers of a DeepSeek Harness profile: status, switch, edit, export; add from the official MCP Registry, by hand (tested first), or with AI; find local software that can be connected
★ 3
↓ 101/wk
MIT
JavaScript
Oct 14, 2026
dsh plugin --profile web add dsh-mcp-consoleby fatefgo2050
Scan and display locally registered MCP tools in the DeepSeek Harness settings page.
★ 0
MIT
JavaScript
Sep 21, 2026
dsh plugin --profile web add dsh-mcp-scanner