zhe97396-tech/dsh-lazy-mcp ↗★ 0

dsh-lazy-mcp

提供按需延迟加载的MCP网关,支持空闲自动释放。 适合需要连接多个MCP服务但希望降低开机常驻内存占用的用户。

套件
dsh-lazy-mcp
相容性
待驗證
版本
0.4.0
授權
MIT
最近更新
2026年10月19日

安裝

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:zhe97396-tech/dsh-lazy-mcp

dsh-lazy-mcp

English | 简体中文

CI License: MIT Node

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 MCP status panel

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.

Behaviourdsh-mcp-clientthis plugin
Server process at Harness startupstartednot started
Server startsat plugin activationon first tool call that needs it
Server stopsonly on config reload / Harness restartafter idleDisconnectMs idle
Standing tool definitionsone per MCP tool, sent every request3 fixed gateway tools
Tool schemas in contextall, alwaysonly when mcp_tools is called
Describing a dormant serverrequires a connectionserved from cached metadata
Per-server lifecycleone behaviourlazy / 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

HalfFileLoaded by
Hostindex.jsthe bundle row in cordis.patch.yml
Browserclient.jsthe 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

WhatWhere
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)
Sourcethis 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 fieldEffect here
lifecycleused directly (lazy / lazy-keep-alive / eager / keep-alive)
idleTimeoutminutes in Pi, converted to idleDisconnectMs
disabled: true / enabled: falsethe 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:

  1. each configPaths file, in order
  2. an inline mcpServers mapping in the row config
  3. the native servers array
  4. 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:

SituationBehaviour
a configPaths file is missingwarned and skipped
an overrides key matches nothingwarned and ignored
a configPaths file is malformed JSONactivation 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.

ModeConnectsIdle-disconnectsUse when
lazyon first useyes, after idleDisconnectMsthe default: cheap to start, used occasionally
lazy-keep-aliveon first usenever after the first useexpensive to start — you do not want to re-pay startup every idle window
eagerbegins at activationno, unless idleDisconnectMs is set explicitlyyou want it warm, but a slow server must not delay startup
keep-alivebegins at activationno, unless idleDisconnectMs is set explicitlyalways-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.

ToolConnects?Purpose
mcp_serversneverList servers with lifecycle, state, tool count, metadata source, last error, connection count
mcp_toolsonly with refresh: trueList one server's tools with input schemas; served from cache while dormant
mcp_callyes, on demandInvoke server + tool + arguments, then reset the idle timer
mcp_resourcesyes, alwaysList one server's resources and resource templates; report "no resources" rather than failing
mcp_read_resourceyes, on demandRead 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