DSH Plugins Marketplace

DSH Plugins

Plugins

/

Tools & Capabilities

/

dsh-lazy-mcp

d

dsh-lazy-mcp

Manifest valid

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

UI (client)hasBundlePatch

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-<version>.tgz from the Releases page and install it through the plugin manager (or dsh plugin --profile web add ./dsh-lazy-mcp-<version>.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-<version>.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-<version>.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: <DSH_PROFILE_DIR>/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 <DSH_PROFILE_DIR>/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 cannot wake a server — the route reads the in-memory records and never touches a transport.

BadgeMeaningDerived fromToken
正在使用 (in use)socket open, used within 120 sstatus === 'ready' and last call within 120 s--dsw-alias-state-success-primary
已连接 (connected)socket open, idlestatus === 'ready', idle longer than that--dsw-alias-brand-primary
连接中 (connecting)connect in progressstatus === 'connecting'--dsw-alias-state-warn-primary
休眠 (dormant)no process, no errorstatus === 'dormant', no recorded error--dsw-alias-state-idle-primary
连接失败 (failed)last attempt failedlastError present — wins over the transport state--dsw-alias-state-error-primary

The status labels are Chinese in the UI; the meaning column above is the reference for a localization change (see Contributing).

The last row is the one that matters: a server whose connect threw keeps status: 'dormant', so the Host records lastError beside it and the panel reports failure rather than a reassuring grey dot.

Only three of those five are real Host states. The Host has exactly dormant, connecting, and ready; the panel splits ready into two colors by recency. 正在使用 and 已连接 are the same underlying state — the socket is open and the tool list is live in both — and the 120-second line between them is presentation only: it changes nothing about behaviour and nothing about when the server is dropped. The only line that does anything is idleDisconnectMs.

So, with the defaults, one use produces this timeline:

a call ──┬─ 0–120 s ────── 正在使用   (ready)
         ├─ 120–600 s ──── 已连接     (ready)
         └─ > 600 s ────── 休眠       (dormant, process released)

The countdown is armed at connect and reset by every use, so the window is "10 minutes since the last activity", not "10 minutes since connecting". Activity means mcp_call or mcp_tools { refresh: true } — both touch the server. mcp_servers and a cached mcp_tools do not reset it, because neither touches the server; that is what makes "look at the tool list" free.

To watch all three states in a couple of minutes, set the window to 180: 120 s of 正在使用, 60 s of 已连接, then 休眠.

The route exposes names, states, and counts only — never the transport target (which can be a command line) and never an environment value. GET is read-only and cannot wake a server. webServer is a soft dependency read through ctx.get, so headless and SDK profiles load the plugin with the three gateway tools and simply have no panel.

The in-conversation chip

The panel is one click away, but the question "is anything connected right now?" should not cost a click. The browser half therefore also registers into conversation.session.header.utilities — the right-aligned Session utilities, between the session-log download (order 0) and dsh-better-sidebar's bottom toggle (order 10) — as a small chip.

The in-conversation chip

The chip in the session header: a green dot and the connected server names.

ConditionChip
Every server dormantNothing is rendered
≥1 server ready, none used in the last 120 sAccent dot + the connected names
≥1 server ready and used within 120 sGreen dot + the connected names
≥1 server connectingAccent dot + the names

Rendering nothing when everything is dormant is deliberate. Absence is the signal, and a chip in every session — including the many that never touch an MCP server — would be permanent clutter carrying no information. The tooltip carries the detail: the connected names, the failed count, and the effective idle window.

The chip and the panel share one poller: a module-level store with a listener set, so the two consumers produce one request per interval instead of two, and polling stops entirely when the last consumer unmounts.

The idle window is adjustable from the panel

The panel carries one setting: the idle window, entered in seconds. The stored value is milliseconds and the Host validates the range (MIN_IDLE_DISCONNECT_MS…MAX_TIMER_DELAY_MS), but seconds are what a person can picture — 600 is a window, 600000 is a number to misread.

VerbEffect
GET /lazy-mcp/stateThe snapshot
POST /lazy-mcp/state { "idleDisconnectMs": 1800000 }Set the window, re-arm every connected server's deadline, persist
DELETE /lazy-mcp/stateDrop the override and restore the configured value

Precedence: the panel wins over the row config. The override is persisted at <cache directory>/settings.json — beside the metadata cache, so the plugin owns one directory and reuses the cache path's own fallback chain — and it survives a restart. "Restore default" deletes the file rather than writing an empty object, because an absent file is the canonical "no override".

Two deliberate limits:

  • A per-server idleDisconnectMs in the config is not moved. It is a deliberate per-server decision, so only servers without one follow the panel. The snapshot reports idleDisconnectMsExplicit per row.
  • Re-arming touches only ready records. A dormant server has no deadline to move, and arming one would show a timer that is not running.

Neither verb connects, disconnects, or reconfigures a server. The request body is bounded to 4 KiB and validated before anything is written.

Delivery classes

A failed mcp_call reports how far the request got, because retrying a call that may already have run is how you double-charge a card or post a comment twice. Every failure message carries [delivery=…] plus explicit guidance.

ClassMeaningGuidance given to the model
not_sentThe request never reached the server; nothing ranFix the cause and retry
server_errorThe server answered with an MCP errorThe tool may have partially run; read the error first
may_have_runThe request was sent and the outcome is unknown (timeout, cancellation, transport loss)Do NOT retry automatically — confirm the effect or ask the user

A may_have_run failure also resets the connection, so the next call starts from a clean transport.

Conservative by design: a rejection that happens after dispatch is classified may_have_run even when it is probably harmless. Client-side argument validation by the MCP SDK also lands in may_have_run, not not_sent, because this plugin cannot prove nothing was sent. Erring pessimistic is the safe direction.

Idle lifecycle

  1. Activation registers three tools and marks every server dormant (except eager / keep-alive, which begin connecting in the background). No process is spawned and no connection is opened.
  2. The first mcp_tools { refresh: true } or mcp_call for a server connects it and arms the idle timer.
  3. Each later use re-arms the timer. mcp_tools without refresh does not, because it never talks to the server.
  4. When the timer fires, the transport/client is closed, the child process is released, and the server returns to dormant with cached metadata intact.
  5. Plugin unload or HMR disposes every live connection through ctx.effect and waits for any in-flight cache write.

Verification

verify.mjs drives the plugin's real apply() in a fresh Node process with a stub Cordis context, exercising the implementation rather than a copy of it:

pnpm run verify   # or: node verify.mjs

It covers the empty configuration; the cache round trip (including "serving the cache did not reconnect"); all four lifecycle modes; all three delivery classes; standard mcpServers import with precedence, overrides, ${VAR} expansion and Pi's minutes-to-ms idleTimeout conversion; the tolerance rules; the cache path fallback; a real Pi configuration when one exists on the machine (skipped otherwise); and the resource half (listing, reading, template substitution, and the binary projection). Current result: 76 checks, 0 failures. It spawns the MCP reference server through npx, so the first run downloads that package, and it needs network access.

CI runs the check, lint, pack, and the full verification harness on Linux and Windows.

Two one-off probes sit beside it for the question "what does this server actually expose?":

node probe-capabilities.mjs   # tools / resources / prompts capabilities, one server
node probe-resources.mjs      # full resource + template surface, plus one read

This harness exists because the DSH Loader pins an imported module generation: a running Harness cannot pick up edited plugin JavaScript without a restart, so in-host testing cannot see code changes. See Development notes.

Known limitations

  • Text-only results. Raw MCP blocks are summarized: text passes through, images/audio/resources become explicit [image omitted: …] markers. The official bridge additionally performs durable image projection through the attachment service; that is deliberately not reimplemented here.
  • Binary resources are reported, not delivered. A blob body becomes a size notice; the bytes stay with the server. Programmatic callers would need the official bridge.
  • No re-notification sync. If a connected server changes its tool list mid-session, the cached list is refreshed on the next connect rather than by a list_changed subscription. Resource lists are never cached, so they are always read live.
  • No OAuth. Authenticated servers use static headers.
  • No prompt templates, resource subscriptions, or task-based execution.
  • No directTools. Every MCP tool is reached through mcp_call; none is registered as a first-class tool with its own schema in the tool catalog.
  • No includeTools / excludeTools filtering, and no searchKeywords.
  • mcp_servers reports cached state and never probes a dormant server.
  • UI status labels are Chinese. Behaviour is unaffected; a localization PR is welcome.

Development notes

Two behaviours of the DSH plugin system shape how this package must be worked on.

  1. A workspace-authored bundle is installed as a symlink, so its dependencies must live beside the source. install_bundle links the workspace directory into the profile's node_modules, and Node resolves the realpath, so it looks for node_modules next to the source file. Hence the .npmrc node-linker=hoisted: pnpm's default isolated layout keeps dependencies under node_modules/.pnpm, which is unreachable from the symlinked path. The failure mode is failed to import / Cannot find package …. Installing a packed tarball or from GitHub sidesteps the whole problem.
  2. Edited JavaScript needs a dsh web restart. The Loader pins the resolved module generation, so toggling the row off and on re-applies the config (which does hot-reload) but re-imports the cached module. Changing the entry file path or the package name does not defeat this. Edit, then restart.

Three defects worth remembering

  1. The metadata cache silently never wrote in production. resolveCachePath trusted $DSH_PROFILE_DIR / $DSH_HOME, but the Host process does not inherit them — those variables are injected into shell tool calls, not into the Host. The harness passed because it supplied an explicit cachePath. Fixed with a ~/.dsh fallback and now covered by a test that deletes those variables first. Only live testing could have caught this.
  2. An idle-disconnected record kept claiming live metadata, so the cache was never consulted and mcp_tools reconnected instead of describing. The harness caught this one, but only because the cache test asserts on connection counts rather than on the returned text alone.
  3. A timer assertion was flaky. Waiting 8s for a 6s idle window passed most runs and failed occasionally under load. Every idle assertion now waits more than twice its window. A test that races a timer needs a margin, not a guess.

The lesson for this plugin: assert on the state machine, not on rendered text; never trust an environment variable the Host may not have; and give every timer assertion a generous margin.

Contributing

Bug reports, fixes, and documentation patches are welcome — see CONTRIBUTING.md for the development setup and the repository invariants.

License

MIT © 2026 zhe97396-tech

Comments

Loading…

Similar plugins

dsh-mcp-lazy

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.

Manifest valid

★ 10

↓ 119/wk

MIT

TypeScript

Oct 20, 2026

dsh plugin --profile web add dsh-mcp-lazy

MCP server marketplace for DSH: browse a curated, npm-verified catalog and install MCP servers into the current profile with one click, live without restart.

Tools & CapabilitiesDevelopment & InfrastructureManifest valid

★ 0

dsh plugin --profile web add dsh-mcp-market

by appthin

MCP management page in DSH Settings: list, enable/disable, restart and delete MCP servers, edit them in form or JSON mode, expand a server to see the tools it registers, and paste-import MCP configs f

UI & ExperienceTerminal & ClientsManifest valid

★ 12

↓ 569/wk

MIT

JavaScript

Oct 15, 2026

dsh plugin --profile web add dsh-mcp-manager-plus

by whiteS18

MCP server management panel in Settings: add, edit, enable/disable and delete servers across user and workspace scopes with form or JSON editing, and auto-register discovered MCP tools as native conve

UI & ExperienceManifest valid

★ 0

MIT

JavaScript

Oct 8, 2026

dsh plugin --profile web add dsh-mcp-servers-panel

DeepSeek Harness bundle: manage MCP servers — list, probe, add from pasted project references, toggle, remove, rotate credentials. Official dsh-mcp-client contract compliant.

Tools & CapabilitiesManifest valid

★ 0

NOASSERTION

JavaScript

dsh plugin --profile web add @dsh-contrib/dsh-mcp-manager

A CodeGraph tool scoped to the session's own indexed project, resolved from its workspace directory. One MCP server is launched through npx per project root and shared by every session working there;

Tools & CapabilitiesManifest valid

★ 0

dsh plugin --profile web add dsh-plugin-codegraph-project