zhe97396-tech/dsh-lazy-mcp ↗★ 0
dsh-lazy-mcp
提供按需延迟加载的MCP网关,支持空闲自动释放。 适合需要连接多个MCP服务但希望降低开机常驻内存占用的用户。
安裝
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:zhe97396-tech/dsh-lazy-mcp說明文件
閱讀完整 README ↗dsh-lazy-mcp
A lazy-loading MCP gateway for DeepSeek Harness (DSH). No MCP server starts at boot: each server connects on first use and is released after an idle window. Tools and resources are bridged into the model's tool catalog, and the Web UI shows live server state.

The status panel: per-server state, lifecycle, tool count, and the adjustable idle window.
Why this exists
@deepseek-ai/dsh-mcp-client connects every configured server while the plugin activates and awaits the initial connection plus tool discovery, so:
- every server is live before the Harness's first turn;
- a slow or broken server delays startup;
- servers stay resident until the Harness restarts (its only
idle-shaped knob,reconnect.maxDelayMs, governs reconnect backoff, not idle shutdown).
This gateway starts nothing at boot.
| Behaviour | dsh-mcp-client | this plugin |
|---|---|---|
| Server process at Harness startup | started | not started |
| Server starts | at plugin activation | on first tool call that needs it |
| Server stops | only on config reload / Harness restart | after idleDisconnectMs idle |
| Standing tool definitions | one per MCP tool, sent every request | 3 fixed gateway tools |
| Tool schemas in context | all, always | only when mcp_tools is called |
| Describing a dormant server | requires a connection | served from cached metadata |
| Per-server lifecycle | one behaviour | lazy / lazy-keep-alive / eager / keep-alive |
Install
Requirements: Node.js 20 or newer and a DSH profile (the Web profile is the usual one). The package has no @deepseek-ai/* dependency, so a Harness peer-range change cannot break it; its single runtime dependency is the MCP TypeScript client SDK, the same SDK the official bridge uses.
Option A — install from GitHub
dsh plugin --profile web add github:zhe97396-tech/dsh-lazy-mcp
Then restart dsh web. The Host half and the browser half are both loaded from the installed package, and a running Harness cannot pick up a freshly installed module without a restart.
Option B — install a release tarball
Download dsh-lazy-mcp-.tgz from the Releases page and install it through the plugin manager (or dsh plugin --profile web add ./dsh-lazy-mcp-.tgz), then restart dsh web.
The plugin is enabled by an - id: lazy-mcp row in the profile. A configuration-only change normally hot-reloads, but when it ships inside a replaced package the manager defers it to the restart (the result reads application: "restart-required"). Toggling the row off and on — the plugin manager's set_plugin on include:lazy-mcp — forces a re-compose and applies the new configuration immediately, without a restart.
Two halves
| Half | File | Loaded by |
|---|---|---|
| Host | index.js | the bundle row in cordis.patch.yml |
| Browser | client.js | the dsh.client declaration in package.json, served by dsh-client-modules |
The browser half imports no DSH Client package: React comes from the browser module table and every color is a theme token, so it follows light and dark without a second stylesheet. Its __ModuleLoader__.load({ id }) uses the exact package name, which is why the package name, the Loader row name, and the naming manifest must be renamed together.
The two halves are wired only by the read-only JSON route the Host half registers at /lazy-mcp/state and by the shared panel id lazy-mcp.
Where it actually lives
| What | Where |
|---|---|
| Profile dependency + bundle list | ~/.dsh/profiles/web/package.json |
Installed package — a real directory with its own node_modules | ~/.dsh/profiles/web/node_modules/dsh-lazy-mcp |
| Vendored tarball (only when installed from a tarball) | ~/.dsh/profiles/web/.local-packages/dsh-lazy-mcp-.tgz |
| Enablement override written by the plugin manager | ~/.dsh/profiles/web/cordis.patch.yml (- id: lazy-mcp) |
| Source | this repository — not read at runtime |
The plugin's settings live in this package's own cordis.patch.yml, loaded as the bundle's patch layer — not in the profile's cordis.patch.yml. To override a setting from the profile layer instead, add an - id: lazy-mcp entry with a config there; the profile layer wins.
Rebuilding after a code change
cd dsh-lazy-mcp
pnpm install
pnpm run check # syntax check of both halves
pnpm run lint # package identity / manifest consistency
pnpm run verify # self-check harness (starts a real MCP reference server)
pnpm pack # dsh-lazy-mcp-.tgz
Bump version first, so the new artifact gets a distinct name and the old one can be pruned, then install that tarball through the plugin manager and restart dsh web.
Configure
The package ships with no servers configured (servers: []), so nothing is spawned until you add entries to the lazy-mcp row's config:
- id: lazy-mcp
config:
idleDisconnectMs: 600000 # disconnect a lazy server after 10 minutes idle
connectTimeoutMs: 30000 # deadline for connect + tool discovery
callTimeoutMs: 60000 # deadline for one tools/call
# cachePath: !!js dshHomePath('lazy-mcp/tool-cache.json') # default: /lazy-mcp/tool-cache.json
servers:
- name: docs # ^[A-Za-z0-9_-]{1,32}$, unique
transport: stdio
lifecycle: lazy
description: Documentation lookup
command: npx
args: ['-y', 'some-mcp-server']
# env: { SOME_TOKEN: '...' } # optional, merged over the scrubbed env
# cwd: 'C:\path\to\project' # optional
# idleDisconnectMs: 120000 # optional per-server override
- name: knowledge
transport: streamable-http
lifecycle: lazy
description: Internal knowledge base
url: https://mcp.example.com/rpc
# headers: { Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`' }
Every field is optional except a server's name, transport, and that transport's target (command for stdio, url for streamable-http). An invalid configuration fails activation with the exact offending path named.
Environment scrubbing
A stdio server's child environment is the parent environment minus every name matching KEY|PASSWORD|SECRET|TOKEN (case-insensitive) and minus every DSH_* name, then merged with the server's explicit env. This matches the shipped subprocess seam's definition, so a child server never inherits this process's model credentials. A server that needs a secret must receive it through its own env mapping. Env values are never logged and never written to the cache — only the key set is, and only to detect a configuration change.
Importing an existing MCP setup
Point configPaths at any JSON file containing a standard mcpServers object and its servers appear here. This is how an existing Pi (pi-mcp-adapter), Claude Code, Cursor, or .mcp.json setup becomes reusable without copying commands by hand — the other tool stays the source of truth.
- id: lazy-mcp
config:
configPaths:
- '~/.config/mcp/mcp.json' # Pi shared config
- '~/.pi/agent/mcp-adapter.json' # Pi adapter config
Standard fields are translated: command/args/env/cwd become a stdio server, url/headers become a streamable-http server. A leading ~/ in a path, and ${VAR} / $VAR in args, cwd, env, headers, and url, are expanded from the environment — an unknown name is left literal rather than becoming an empty string, so a mistake stays visible.
Pi-specific tuning is honoured where this plugin has an equivalent:
| Pi field | Effect here |
|---|---|
lifecycle | used directly (lazy / lazy-keep-alive / eager / keep-alive) |
idleTimeout | minutes in Pi, converted to idleDisconnectMs |
disabled: true / enabled: false | the server is skipped |
type: "sse" | rejected with a clear error — SSE is not supported |
directTools, includeTools, excludeTools, and searchKeywords are ignored; they have no equivalent here.
Precedence and overrides
Later sources win on a name collision:
- each
configPathsfile, in order - an inline
mcpServersmapping in the row config - the native
serversarray - per-field
overrides
overrides patches individual fields of an imported server, so you can give it a description or a lifecycle without restating its command:
overrides:
chrome-devtools:
description: Browser automation and debugging
lifecycle: lazy-keep-alive
An entry with disabled: true removes that name entirely, which is how you switch off an imported server without editing the other tool's file.
Tolerance rules
The imported files are a live source that can change under you, so a problem there must not stop the Harness from starting:
| Situation | Behaviour |
|---|---|
a configPaths file is missing | warned and skipped |
an overrides key matches nothing | warned and ignored |
a configPaths file is malformed JSON | activation fails loudly |
a server entry is invalid (no command or url, bad name) | activation fails loudly |
The rule is: absence is tolerated, corruption is loud.
Lifecycle modes
Set per server with lifecycle. The default is lazy.
| Mode | Connects | Idle-disconnects | Use when |
|---|---|---|---|
lazy | on first use | yes, after idleDisconnectMs | the default: cheap to start, used occasionally |
lazy-keep-alive | on first use | never after the first use | expensive to start — you do not want to re-pay startup every idle window |
eager | begins at activation | no, unless idleDisconnectMs is set explicitly | you want it warm, but a slow server must not delay startup |
keep-alive | begins at activation | no, unless idleDisconnectMs is set explicitly | always-available servers |
eager and keep-alive start connecting when the plugin activates but activation never waits on them, so a slow or broken server cannot delay Harness startup. A failed background connect is logged, not thrown.
After the first successful connect, a lazy-keep-alive server is reported as pinned=yes and its idle timer is never armed again.
All four modes recover the same way: if a connection drops, the next call reconnects. There is no background health check or reconnect loop.
Metadata cache
The first successful connect writes the server's tool list to /lazy-mcp/tool-cache.json (atomic temp-file-plus-rename write). While a server is dormant, mcp_tools answers from that cache and does not start the server:
mcp_tools { server: "docs" } -> cached list, server stays dormant
mcp_tools { server: "docs", refresh: true } -> connects and re-reads live metadata
The cache is keyed by a fingerprint of the server's transport, command, arguments, working directory, URL, and env key set. Changing any of those makes the entry unusable, so a stale cache can never describe a server that has since been reconfigured. A missing, unreadable, malformed, or wrong-version cache is never an error; it just means no server has cached metadata yet.
After an idle disconnect the record falls back to the cache, so mcp_servers reports tools=13(cache) rather than claiming live metadata it no longer has. If the cache write has not landed yet, it reports (memory) instead — the in-process list is still used rather than forcing a reconnect.
Tools
MCP exposes three primitives: tools (functions the model calls), resources (documents and data it can read), and prompts. This gateway bridges the first two. Prompts are not bridged.
| Tool | Connects? | Purpose |
|---|---|---|
mcp_servers | never | List servers with lifecycle, state, tool count, metadata source, last error, connection count |
mcp_tools | only with refresh: true | List one server's tools with input schemas; served from cache while dormant |
mcp_call | yes, on demand | Invoke server + tool + arguments, then reset the idle timer |
mcp_resources | yes, always | List one server's resources and resource templates; report "no resources" rather than failing |
mcp_read_resource | yes, on demand | Read one resource by exact uri, then reset the idle timer |
Typical flow:
mcp_servers -> which servers exist, which are dormant
mcp_tools { server: "docs" } -> cached list; server NOT started
mcp_tools { server: "docs", refresh: true } -> starts it; live list
mcp_call { server: "docs", tool: "search",
arguments: { query: "x" } } -> runs it; resets the idle timer
mcp_resources { server: "kb" } -> what it can READ; connects
mcp_read_resource { server: "kb",
uri: "kb://doc/42" } -> the body; resets the idle timer
Concurrency: two calls that need the same dormant server share one connect attempt; calls to different servers are independent.
Resources
mcp_resources is the read half. It exists because some servers put nearly all their value in resources — a filesystem server exposes files, a knowledge base exposes documents, a database server exposes schemas and rows — and a tools-only bridge connects to those and then shows you nothing.
It always connects. mcp_tools can answer from cache while dormant; a resource list cannot, because holding one stale is worse than paying for a connection. A dormant server therefore wakes on the first mcp_resources call, and the idle timer reclaims it as usual. A server that answers "method not found" for resources/list gets a plain sentence saying so, not a failure.
Binary bodies are never inlined. A resource carries either text or blob. A base64 body would spend the context window on bytes the model cannot use, so it becomes a size notice — the same projection the official dsh-mcp-resources uses:
demo://resource/dynamic/blob/42 text/plain
[binary resource: 72 base64 characters; available to programmatic callers]
Text bodies are returned whole up to 8,000 characters per body, with the uri and mime type above them.
Templates are listed, not expanded. A template like demo://resource/dynamic/text/{resourceId} is shown as-is with a note to substitute the variables and read the resulting uri; the gateway never guesses what belongs in a placeholder.
Both tools connect through the same ensureReady the tool side uses, so a resource read shares the lazy lifecycle, the idle reclaim, and the delivery vocabulary. Neither is ever cached to disk — only tool metadata is.
Status panel
The browser half adds one entry to the sidebar (sidebar.panellist, order 40, label MCP) and the panel behind it (main, key lazy-mcp). The shell owns the button: a sidebar.panellist id addresses the main panel of the same name, so this plugin writes no navigation code and supplies only an icon (through the slot's { size, active } props) and a panel body.
The panel polls /lazy-mcp/state every 5 s and shows, per server: a state badge, lifecycle, tool count with its metadata source, last-used time, and connection count. Opening it