Repository navigation
Releases: wings1848/dsh-mcp-lazy
Release list
v0.4.1
Fixed
-
Peer ranges accept the
0.2.0-rcharness line. They read^0.1.5-rc.1, and semver does
not let a prerelease of one0.xline satisfy a caret range anchored on another: the
harness's0.2.0-rc.2fails^0.1.5-rc.1outright. Nothing crashed — the damage sat at
install time, where a package manager resolves the unmet peer against the registry and
materializes a private0.1.5-rc.xcopy ofdsh-toolsundernode_modules. The plugin
then builds tool definitions with a differentdsh-toolsinstance than the runtime that
registers them: the class-identity mismatchscripts/link-dsh.mjsexists to prevent. The
ranges are now^0.1.5-rc.1 || ^0.2.0-rc.1in bothpeerDependenciesanddevDependencies,
covering the0.1.5-rcline and the0.2.0-rcline through0.2.0-rc.2(package.json).test/unit/peer-ranges.test.tsnow guards the claim in both directions: the versions this
plugin says it supports must satisfy each range, and so must the version the harness
actually provides. A narrowed range turns red on any machine, and a harness that moves past
the range turns red again.
v0.4.0
Added
-
envFrom: a server's secrets, fetched by a command when the server is spawned. Astdio
entry may now declare a map of variable name → command; each command runs through/bin/sh -c
once per spawn, and its trimmed stdout becomes that variable in the child's environment
(src/env-from.ts,src/connection.ts).argsmay reference any declared name as{{NAME}},
which is the only way to hand a secret to a server that accepts it only as an argument.This complements the
!!jstag rather than replacing it.!!jsis evaluated once, while the
host loads its configuration, so a value rotated afterwards needs a host restart and a vault
that is still locked at boot yields an empty string for the life of the process.envFromis
resolved at spawn time, which is late enough to see a rotated value and early enough to fail
loudly. Nothing is cached.A failure is never an empty value. A non-zero exit, a timeout (10 s default,
envFromTimeoutMs), or empty output refuses to start the server instead of injecting a blank —
the failure mode that motivated this feature was a credential check passing on an empty key and
only failing when the model was actually called.allowEmptynames the variables for which an
empty result is genuinely correct.The value goes nowhere else. Diagnostics carry the variable name, the exit code, and the
command's own stderr — never its stdout. The result is never written back to the entry, so it
never reachescache.json;computeConfigHashhashes the commands and only when at least one
is declared, so an entry withoutenvFromkeeps its existing digest and its cached catalog. The
command itself inherits the scrubbed ambient environment (scrubbedParentEnv), not the entry's
env, and runs in its own process group so a timeout'sSIGTERM— escalated toSIGKILLa
second later — reaps whatever it started (docs/configuration.md).Four new load-time refusals (
assertEnvFrominsrc/index.ts): a name declared in bothenv
andenvFrom, anenvFromon a transport that never spawns, an empty command, and an
allowEmptyentry that names nothing.The fixture server gained a
dump_argvtool, because argument interpolation is only observable
in the child'sargv(test/fixtures/mcp-server.mjs).
Changed
- The design notes are two files instead of four.
docs/design/plan.md,
docs/design/adopt-native-rows.md, anddocs/design/mcp-adopt-command.mdare merged into
docs/design.md(the constant tool surface, the cache, the lifecycle,envFrom, and theadopt
command, each with its invariants, boundary semantics, and known limits); the parity report moves
todocs/parity-pi-mcp-adapter.md. Every reference — README, README-zh, CONTRIBUTING, SECURITY,
troubleshooting, the module doc comments insrc/, and the older changelog entries above — points
at the new paths. Documentation only: no code, no exported surface, no published entry point
changed.
Fixed
- The test counts quoted in
README.md,README-zh.md, andCONTRIBUTING.mdnow match the suite
(388 tests in 90 suites) instead of trailing it.
v0.3.3
Fixed
-
The native-server warning now accounts for
directTools. All four of its sentences give advice
about bringing a server into this gateway, and every one of them assumes that doing so keeps its
schemas out of the request.directToolsbreaks that assumption, because it makes this gateway
register tools natively itself: "add it here" is then counterproductive for a server only the
native plugin serves, and "remove it from one of the two" is incomplete for one both serve. Each
sentence now ends with the setting named when it applies — "Keeping it here does not stop those
schemas whiledirectToolsis set, because this gateway registers such tools natively itself." —
and names only the affected servers when a group mixes promoted with unpromoted ones
(src/proxy-tool.ts,src/registry.ts).The predicate is
McpGatewayRegistry.promotesNatively, which reads configuration rather than the
catalogs, so its answer does not change after a first connect. It deliberately ignores
directTools: 'search', which stages tools until a search matches rather than registering them.
The case-only sentence asks it about the local spelling, because that is the row its advice tells
the reader to keep — asking about the native name would always answer no. -
A
disabledserver is no longer promoted.directToolSelectionsandsearchModeServersread
the promotion setting and the known catalog but never looked atdisabled, so a switched-off
server's tools were registered as real tools — in every request, whileensureConnectedrejects a
disabled entry outright, which made every call to one fail with "is disabled in configuration".
The documented meaning of that flag is "kept visible in status, never served", and promotion now
agrees with it. Both halves of promotion were affected: the blanket form registered the tools and
the'search'form staged them, and a staged tool becomes native the moment a search matches it.Reachable only with
directToolsset — it defaults to off — and only through the metadata cache,
because connecting is exactly what a disabled entry will not do. That is also why the regression
test seeds the cache rather than a stub connection (src/registry.ts).
Changed
docs/configuration.mdsays that a disabled server is not promoted, and that the status listing
namesdirectToolswhen it applies.
v0.3.2
Fixed
- Two of the three residues
0.3.1declared are gone. The listing renders a fourth sentence for
a native name that differs from one of this gateway's own only by case: both plugins key servers by
exact name, so the two are separate entries rather than one, and the old "This gateway does not have
it; add it here" left two servers doing the same job while contradicting the listing printed
directly above it. It names the local spelling it nearly matched — the enabled one when a
switched-off variant folds onto it too, and each spelling carries its own(switched off)marker
rather than one flag for the whole sentence — and its
advice reduces rows ("keep one row and delete the rest") rather than merging names. Merging was the
first cut's advice and it was not executable: mcp-client answers a second row sharing a
serverNamewithalready in use by another mcp-client instance(src/proxy-tool.ts). - A natively-enabled entry whose
serverNamethis gateway cannot use is no longer dropped in
silence.detectNativelyRegisteredsubstitutes a placeholder when that field is not a plain
string, and that is what a!!jsexpression looks like in the loader's raw options — the loader
evaluates it only for the config it hands the plugin — so such a row registers tools under its
evaluated name while the listing said nothing at all. The notice enumerates exactly what it counts:
a computed expression, a missing or empty field, or a name outsideSERVER_NAME_PATTERN, which are
the strings the same filter drops. The count is taken before deduplication, because two unmatchable
rows are two rows while two rows sharing a real name are one server — the first cut said1 entry
for two rows, and counted the placeholder alone, so a blank row beside a computed one was invisible
to it. The wording claims matchability, not absence: a configuration may spell the placeholder
literally, and "has noserverName" would be false of that one. - The placeholder has a name and a doc rather than a bare literal inside
detectNativelyRegistered's loop:UNNAMED_NATIVE_NAMEinsrc/types.ts. The listing does not
read it back, because it treats every unmatchable string the same way.
The third residue stands. With directTools: true this gateway promotes tools natively itself, so
neither sentence's advice restores the saving on its own; fixing it means threading that setting into
renderStatus, which the registry does not expose (src/proxy-tool.ts, src/registry.ts). It stays
declared rather than quietly dropped.
Changed
docs/configuration.mdcarries both new sentences in its table of whatmcp({})says.
v0.3.1
Fixed
-
The native-server warning now says only what it checked.
detectNativelyRegisteredreports
every enabled@deepseek-ai/dsh-mcp-cliententry, whether or not this gateway serves it too,
while the notice asserted overlap for all of them — "is also configured in … Both plugins now
work" — and told the user to "move those servers here". Two adversarial reviews of the fix
found five clauses that were wrong, each in a state the tests did not cover:- "move those servers here" was unsafe. For an entry this gateway already lists with
disabled: true, the server is not absent — it is here and switched off. Adding a second
entry with the sameserverNamemakes the registry constructor throwmcp-lazy: duplicate serverName, so following the advice would have stopped the plugin from loading. That state
now says to clear the flag instead. - "Both plugins now work" was a liveness claim, and the listing it is appended to prints
failedplus the spawn error for a server whose start failed. It contradicted its own output. - "so those schemas enter every request" was another one, this time about the other plugin. A
native row whoseconfigmcp-client rejects — it requirestransportpluscommandorurl,
not justserverName— registers nothing, and neither does a row whose server is down, because
mcp-client drops a server whose reconnect budget is spent. The sentence now states that
plugin's mode rather than an effect on the current request. - A native entry with no usable
serverNameregisters nothing either, so it was named in a
warning it could not belong to. Names failingSERVER_NAME_PATTERNare ignored, as are
non-strings from the exported array seam, whereRegExp.testcoerces andjoinrendered
undefinedas nothing —()reached a sentence. The container is checked too: only an array
is read, so a JavaScript caller passing anything else degrades to no warning at all — where
0.3.0raisedTypeError: native.join is not a function, and the revision in between spelled
a bare string'abc'into three servers nameda,bandc. - A name a duplicated loader entry repeats is counted once instead of twice.
The warning renders one of three sentences — configured both here and there, listed here with
disabled: true, or absent here — each with its plural form, each stating configuration and the
config action that applies, and each offering to disable the native row only "if you do not need
it", since that leaves nothing serving the server. Every sentence is pinned in
test/unit/proxy-tool.test.ts; the failed-start contradiction is built against a real child
process intest/unit/connection.e2e.test.ts, andtest/unit/plugin-load.test.tsnow asserts
the sentence rather than just the server names.Residues, all deliberate and none of them silent to the user:
- Case-only differences are reported as absent. This gateway keys servers by exact name, so a
nativenbeside a configuredNreads as a server it does not have — while the listing two
lines above printsN. No clause is false about the name; the diagnosis is wrong about the
server, and it is the one surviving case where a sentence can look like it contradicts the
listing it belongs to. - With
directTools: truethis gateway promotes tools natively itself, so neither "add it
here" nor "remove it from one of the two" restores the saving on its own (promotion also needs
a catalog, so a cold cache promotes nothing). Neither sentence promises an outcome, so neither
is false; both are incomplete in that configuration. - A computed
serverNameis not reported at all.!!jsin a native row's config leaves the
raw expression node in the loader entry, so detection reads it as(unnamed), the pattern
filter drops it, and mcp-client goes on to register whatever the expression evaluated to. That
is a false negative, which is the safe direction, but it is a real one.
- "move those servers here" was unsafe. For an entry this gateway already lists with
v0.3.0
Added
/mcp-adopt— the moveadoptmakes, from inside the GUI. The status output has always
told you a server was configured twice ("move those servers here"), but acting on that meant
leaving the session for a terminal. The command reuses the published CLI rather than
reimplementing it, so the planner, the byte-preserving edits, the backups and the atomic write
stay one code path. A bare/mcp-adoptis a dry run;/mcp-adopt applywrites. It is reached
throughctx.inject(['commands'])rather than the plugin's owninjectlist, because waiting
for a command registry would leave the gateway inactive on any composition that has none — the
one regression this plugin must never ship. Writing is not cancellable (the CLI renames two
files in sequence, so a signal between them leaves half a move), every run is bounded by a
timeout, and a successful write is followed by a re-plan whose result is shown to you, so
"written" comes with evidence instead of an assurance.adoptnow reports the move's blast radius across profiles. The native row usually lives in
the home patch, which every profile reads, while this plugin's row lives in one profile's
patch — so disabling the former takes the server away from every other profile too, and those
profiles have no gateway to receive it. The plan now names them and gives both ways out. On a
machine where this is the configuration, a dry run says so before anything is written.
Fixed
npx dsh-mcp-lazy-adoptdid not work on a clean machine. The CLI is a publishedbin
entry, but the YAML parser it imports sat indevDependencies, wherenpxdoes not install
it:import 'js-yaml'fromlib/adopt-compose.jsandlib/adopt-patch.jsdied with
ERR_MODULE_NOT_FOUNDon the first statement. It worked on the machine it was written on
because an unrelated package had hoisted a copy into the samenode_modules— the kind of
accident that survives every test written against a working tree.js-yamlis now a real
dependency, and a new test scans every bare import inlib/andscripts/against the
manifest so the next one cannot hide.- The
injectcontract in the plugin-load tests is now modelled rather than assumed: the fakes
gained theinjectseam, and the suite asserts the gateway registers its tool with no command
registry present. - A malformed metadata cache no longer stops the plugin from loading. The cache was checked
only as far as "it is an object with a version and aserverskey"; an entry that wasnull,
or one missing itstoolsarray, threw aTypeErrorout of the registry constructor and took
the whole gateway down with it — every server, including the healthy ones. Entries are now
validated one at a time and an unusable one is dropped, which is what the module always claimed
it did. - Cancelling a call is no longer recorded as a server failure. A caller who walked away — a
cancelled request, an aborted signal — was written into the same failure map a genuine startup
error goes to, which opened the 60-second retry window against a server that had done nothing
wrong: the next caller, who cancelled nothing, was refused with a message about a failure that
never happened. A cancellation now leaves no trace; a real failure still backs off. - The on-disk cache is additive across processes instead of last-writer-wins. Every DSH
process pointed at one$DSH_HOMEshares the file, and each wrote back the snapshot it had read
at startup, so a GUI and a CLI deleted each other's catalogs and search kept falling back to a
cold start. The file is re-read and merged before each write. Entries this process cannot judge
are kept rather than filtered against its own configuration — profiles share one cache file and
do not share a server list, so a server this process does not know is far more likely to belong
to another profile than to have been deleted. - The version this plugin reports to every MCP server came from a literal, and had already
fallen two releases behind: servers were told0.1.0while the package was0.3.0. It is read
frompackage.jsonnow, and a test compares the two so the next drift fails. - A long-lived abort signal accumulated one listener per connect attempt. The listener was
registeredonceand never removed when the attempt it belonged to settled first, so a reused
signal grew a listener — and a captured promise — per call, silently, because Node does not warn
at these counts. - A failure inside a catalog-changed listener was recorded where nothing read it. The empty
catch that used to swallow it was replaced, but the map the replacement wrote to had no
consumer outside its own test, so the failure was still invisible tostatus. The registry now
falls back to the connection layer's errors, and a server that is up but whose refresh threw
reports the reason without being downgraded to failed. - A promoted native tool stayed callable after its server withdrew it. Promotion only ever
added: a tool the refreshed catalog no longer offered kept its native registration and went on
dispatching to a name the server had dropped, failing on every call. Promotion is now
revocable.freezeDirectToolsstops the surface from growing; it does not pin a withdrawn
tool in place, because a stable-but-broken registration is worse for the model than one that
disappears — the proxy still reaches it either way. - The two result renderers had drifted. The proxy path and the promoted-native path each had
their own copy, and the native copy droppedstructuredContententirely: a server returning
only structured content showed "returned no content" once promoted and the JSON when it was
not. One renderer insrc/projection.tsnow serves both. dsh-mcp-lazy-adoptcould not write on Windows. The staged temp file name was built by
splitting on/, which returns the whole path on a system that separates with\— putting
C:\…into a file name, where the colon is illegal. Every run there failed with "cannot
write". It asksnode:pathnow, which also makes the Windows contract testable from a POSIX
host — the reason the bug reached a release at all.- The cache directory and file are created
0700and0600; the file previously took the
ambient umask.
Changed
- Lint is now a gate.
oxlintplus a zero-dependency column checker run insidecheckand
therefore inside CI. The checker enforces the.editorconfiglimit oxlint does not implement
(it hasmax-lines, notmax-len), and its baseline is a per-file count that fails when it
grows rather than a list of exempt files. failureBackoffMsis a supported setting: milliseconds, minimum0, default60000, and0
retries immediately. It had been reachable and effective all along while the documentation and
the type both said no user-facing spelling existed.idleWindowMsaccepts only a function. A scalar used to pass validation and then be discarded,
which is the failure mode this plugin exists to prevent: a setting that looks configured.- The
RECONNECT_*constants are gone. Nothing referenced them, and the plugin has no reconnect
timer — a dropped server is restarted by the next call that needs it. executeProxyis a dispatcher over per-action handlers, and both result paths share one
renderer. The model-facing surface is byte-identical:11parameters,1525bytes.- The README documents the four search parameters (
regex,includeSchemas,limit,offset),
which had no user-facing description at all, and namesdirectTools, which the landing page
never mentioned.
v0.2.1
Fixed
- The
mcptool did nothing at all. A host that reservesmcpfor "call any
tool" dispatches arguments to that name as
{ tool: "<the tool being called>", args: <its arguments> }rather than passing
them through. This plugin's tool is calledmcp, so it collides with the
reserved name and received the envelope:{ search: "x" }arrived as
{ tool: "mcp", args: { search: "x" } }, the gateway readtoolas a tool to
call on some MCP server, and every call answeredNo known MCP tool named "mcp". The plugin loaded, listed its tool, and could not search, describe,
connect or call anything — which is every reason it exists. The envelope is now
unwrapped before dispatch. Reported by the maintainer, who noticed the tool
answering nonsense immediately after installing 0.2.0.
v0.2.0
Added
adopt: a command that moves servers configured under@deepseek-ai/dsh-mcp-clientinto
this plugin. Every writer of MCP configuration in this ecosystem produces such a row — the
config-manager panel,@hyzyn/dsh-codegraph, and a hand-written config — and a server listed
in both places cancels the saving this plugin exists for with no error and no symptom. The
command composes every patch layer the way a boot does, marks each original row
disabled: truein place, and appends the server to this plugin'sserverslist. Non-target
bytes are untouched — comments, blank lines and!!jsexpressions survive exactly — a row it
cannot move safely is reported with a reason instead of being half-moved, and the default is a
dry run. Reachable asdsh-mcp-lazy-adopt, ornode scripts/adopt.mjs.mcp-lazy/adoptis now an export path, so the planning function can be reused:planAdoption
is pure, takes the composed rows and the existing config, and returns a plan whoseedits
array is a byte-exact edit script.- The test counts quoted in
README.md,README-zh.mdandCONTRIBUTING.mdnow match the
suite instead of trailing it.
Fixed
- The "also configured in
@deepseek-ai/dsh-mcp-client" notice inmcp({})is no longer
computed once at plugin load. The loader re-runs only the entries whose own config changed,
so a server moved out of the other plugin by editing another layer left this plugin reporting
a conflict that no longer existed — wrong in the direction that hides a restored saving. The
server list is now read per status render. - A co-mounted row is recognised as disabled whether the flag sits on the loader entry or on the
row itself (disabled: trueinside aninsertitem), which is the formadoptwrites. - The
adoptcommand's entry-point check resolves symlinks, so thedsh-mcp-lazy-adoptbin entry
actually runs once installed. Comparingprocess.argv[1]againstimport.meta.urlwithout
resolving them made the installed command print nothing and exit 0 — indistinguishable from
"nothing to do", which is the one failure this command must never have. - Three ways
adoptcould damage or silently skip a configuration, found by an adversarial review
before release. Each is covered by a test that fails without its fix, and the fixes are described
indocs/design/adopt-native-rows.md§12:disabled: truecould be inserted inside a nested block — a block scalar or anenv:map
holding aname:of its own — producing a file the loader cannot parse, after reporting
success. The flag now goes on the row's own key column.- A row's editable range covered its whole
- insert:block, so a block holding two rows made the
second one unfindable and aborted the run. Ranges are now one item each. - A row was located by the file the dump's provenance marker named, and dropped when that file did
not literally declare it. Rows are now looked up in every patch layer, and one that cannot be
located is reported and counted as a failure rather than skipped in silence.
adopt --writeis now all-or-nothing. Every replacement is written beside its file first and the
files are swapped in afterwards; a failure in the swap restores the already-swapped files from
their backups. Previously a failure on the second file left the first one rewritten, which
disables the original row without adding the server anywhere — the server then belongs to neither
plugin.adoptno longer rejects a whole file over a trailing comment on theservers:key, and no
longer leaves a CRLF file with mixed line endings or an extra blank line.
Changed
-
createProxyTool's fourth parameter acceptsreadonly string[] | (() => readonly string[]).
Passing an array still works, so this is not a break for existing callers; passing a function
makes the conflict notice live. -
Documentation: three files still said 179 tests where the suite had grown to
195, and none of them explained how to configure a server that needs a token.
docs/configuration.mdnow has a Secrets section covering the!!js+
environment-variable pattern, including the three things about!!jsthat
cost time to discover: it applies to scalars only, an expression starting with
!must be quoted, anddisabledis evaluated before the plugin sees it.
v0.1.1
Added
mcp({})now reports when another mounted plugin is registering MCP
servers natively.@deepseek-ai/dsh-mcp-clientturns every MCP tool into a
real tool definition, so when both plugins serve the same servers nothing
fails and the saving silently does not happen -- the one failure mode with no
symptom to notice. The status line names the servers and tells the model to
say so, which is the only channel that reaches the user. Detection reads the
loader without injecting it, so running outside a cordis host reports nothing
rather than failing, and it reads the declared entry tree rather than runtime
state, so it does not depend on which plugin loads first (src/index.ts,
src/proxy-tool.ts).- A plugin-level
directToolsdefault (trueor'search'), applied to every
server that does not set its own.pi-mcp-adapterexposes the same thing as
settings.directTools, so a configuration carried over from it now behaves
the same way, and "expose everything natively" no longer means editing every
server row. A server's owndirectToolsstill wins, includingfalse, which
is how one server opts out of a plugin-widetrue(src/index.ts,
src/registry.ts,src/types.ts).
Fixed
- Plugin-level fields the plugin does not implement were accepted and then
ignored, the same way server-level ones were. The server check cannot see
them, and schemastery passes unknown top-level keys through identically — so a
typo, or api-mcp-adaptersettings key such assettings, resolved into a
config that looked right and did nothing. Both levels are checked now
(src/index.ts). - A server field the plugin does not implement was accepted and then ignored.
schemastery passes unknown keys through untouched, so a configuration carried
over from@deepseek-ai/dsh-mcp-client— which hasreconnectand
failOnStartupError, neither of which exists here — resolved to a config that
looked correct and did nothing. A misspelled field name behaved the same way.
Both now fail at load. The twodsh-mcp-clientfields get a message saying
what to use instead, because each needs a different answer; anything else is
reported as an unknown field alongside the list of real ones (src/index.ts).
Found by running the plugin against real MCP servers rather than the fixture:
a field that is accepted and ignored produces no symptom, so no test that
asserts behaviour could have caught it.
v0.1.0
First public release. Requires Node.js >=22.18.0 and is ESM only. There is one
runtime dependency, @modelcontextprotocol/sdk; the @deepseek-ai/* packages are
peer dependencies supplied by the DSH host (package.json).
Added
- One constant model-facing tool,
mcp, in place of one native tool per MCP tool.
Its schema is built from a literal and does not depend on which servers are
configured (src/schema.ts): 11 parameters, 1525 bytes on the wire, roughly 381
tokens. That figure is constant no matter how many servers are configured.
Measured againstchrome-devtools-mcp@1.6.0(29 tools), native registration
would cost 21252 bytes ≈ 5313 tokens per request, so this is 92.8% less
(docs/design/plan.md,README.md). - Lazy connections: a server is spawned on first use and reaped once it has been
idle past its window (default 10 minutes;0disables reaping). Concurrent
first calls share one in-flight connection instead of racing, a call in flight
is never reaped, andnotifications/tools/list_changedrefreshes the catalog
without polling (src/connection.ts). - Lifecycle modes
lazy(the default),lazy-keep-alive,eager, and
keep-alive, with a per-serveridleTimeoutoverride (src/index.ts,
src/registry.ts). Onlylazyis reaped: every other mode means "keep this
process", so its window defaults to zero.eagerandkeep-aliveare also
connected while the plugin is being applied;lazyandlazy-keep-alivewait
for first use. - A disk metadata cache at
$DSH_HOME/storages/mcp-lazy/cache.json, invalidated
by a SHA-256 hash of the transport configuration and by a 7-day age bound, with
atomic writes and a corrupt file ignored rather than fatal
(src/metadata-cache.ts). This is what letssearchanddescribeanswer
without starting anything. - Weighted search ranking: camelCase-aware tokenization, per-field weights
(qualified name 12, original name 10, server 8, description 5, keywords 5),
phrase, prefix and substring bonuses, a coverage threshold for queries longer
than two tokens, deterministic tie-breaking, and paging (limitdefaults to 12
and is capped at 40) (src/search-ranking.ts,src/registry.ts). - Suggestions for an unknown tool name, a requirement to disambiguate a name
shared by two servers, and explicit diagnostics instead of a fabricated success
when a server fails (src/registry.ts,src/proxy-tool.ts). - Optional native promotion through
directTools(true, a list of names, or
"search"for register-but-inactive) plusfreezeDirectToolsto stop the
request prefix from moving after the first sync (src/direct-tools.ts). - Output bounding, on by default: server-authored text over 50 KiB or 2000 lines
is cut to its head, the full text is spilled to a temporary file, and the path
is returned to the model. Tool results,describeschemas, and server
instructions are guarded; the gateway's own text is bounded by construction
(src/output-guard.ts). - Failure backoff: after a server fails it is not retried automatically for 60
seconds, and the status text reports how long ago it failed and that retries
are suppressed. An explicitmcp({ connect })still bypasses the window
(src/registry.ts,src/connection.ts). - Bounded stderr capture for stdio servers (last 3 lines, at most 8 KiB) folded
into connection errors, with a per-serverdebug: trueto inherit stderr
instead (src/connection.ts). - Cold-cache guidance:
mcp({})names the servers that have no cached metadata
yet and tells the model toconnectthem once, rather than pre-warming anything
at startup (src/proxy-tool.ts). - Load-time configuration validation: a duplicate
serverName,stdiowithout
command, andstreamable-httpwithouturlfail where the configuration is
written (src/index.ts). Activation stays quiet by default: only servers
explicitly configuredeagerorkeep-aliveare contacted, and those connects
are fire-and-forget after the tool is registered, so an unreachable server
cannot delay the tool surface or fail the plugin load. - Configuration stays compatible with
@deepseek-ai/dsh-mcp-client:serverName,
transport,command,args,env,cwd,url,headers, and
toolCallTimeoutMskeep their meaning, so an existing entry moves into the
serverslist unchanged (src/index.ts,README.md). - Developer tooling:
scripts/link-dsh.mjslinks the peer packages from the
running DSH installation,scripts/measure-surface.mjsprints the constant
per-request cost, andscripts/measure-token-savings.mjsmeasures the saving
against a real server. - 179 automated tests in 47 suites, including real child-process tests for lazy
startup, process reuse, idle reaping, cancellation, timeouts, crash recovery,
and live tool-list refresh (README.md,
docs/design/parity-pi-mcp-adapter.md). - Documentation:
README.md,README-zh.md,docs/design/plan.md(acceptance
criteria AC1–AC18), anddocs/design/parity-pi-mcp-adapter.md(a module-by-module
audit againstpi-mcp-adapterv2.33.0). - Not in this release, by design: OAuth and bearer-token storage, MCP resources
and prompts, sampling and elicitation, forwarding image payloads, shared
processes, and configuration interoperability with other hosts. The boundaries
are listed inREADME-zh.md.
Fixed
Defects found during development — by the parity audit in
docs/design/parity-pi-mcp-adapter.md, by mounting the plugin in a real harness,
by an independent review before this release, and by tests that were observed to
fail first — and fixed before this release:
- A server that completed the MCP handshake but then failed
tools/listleaked
its child process, permanently.connect()only closed the client on the happy
path, so a failure between "the server is up" and "the catalog was read" left a
running child that was never entered into the connection state map — which made
it unreachable by the idle sweep, bydisconnect, and bydispose, the three
mechanisms whose entire job is to end it. Every retry leaked another one. The
failure path now closes whatever it opened. Found by review, not by the suite:
the existing failure fixture exits before the handshake, which the SDK cleans up
on its own, so no test covered the window at all (src/connection.ts). apply()discarded the disposer returned byctx.tools.register(), so the
mcptool was never unregistered on teardown. That disposer is not
fiber-scoped — the harness's own mcp-client keeps and calls the ones it gets —
so an unload-and-reload would leave a stale tool pointing at a registry whose
connection layer had already been disposed. Theapplydisposer also now
awaits the registry and output-guard teardown instead of firing and forgetting
it, since cordis waits for an async disposer and returning early let a reload
race its predecessor (src/index.ts).- The test that claimed to cover that could not fail. Its fake context cleared
the registered-tool list inside its owndisposeAll(), so "no tool may outlive
the plugin scope" passed no matter what the plugin did — the exact
decoration-instead-of-regression failureCONTRIBUTING.mdwarns about. The fake
now returns a real unregister function and leaves the list alone, and the
assertion was confirmed to go red when the plugin-level fix is reverted
(test/unit/plugin-load.test.ts). eagerandkeep-alivenever connected during activation, so both behaved
exactly like their lazy counterparts: a documented setting that silently did
nothing. Nothing inapply()or the registry ever acted on the "connect during
activation" half of the lifecycle contract, and the existing suite could not
catch it because that suite asserts activation stays quiet — correct for the
default mode, wrong for these two. Found by booting the plugin in an isolated
DSH profile and watching a fixture server's start counter stay empty.apply()
now connects exactly the serversregistry.residentServers()selects
(src/index.ts,src/registry.ts).keep-alivewas reaped likelazy.resolveServerzeroed the idle window for
eagerandlazy-keep-aliveonly, and this plugin has no separate keep-alive
registry for the idle sweep to skip — which is howpi-mcp-adapterkeeps its
keep-aliveservers alive. The mode promised residency and delivered a
10-minute window. Every mode exceptlazynow resolves to no reaping
(src/registry.ts).scripts/link-dsh.mjsassumed the four peer packages were siblings under one
installation root. That holds for a global bun install but not for pnpm, where
each package gets its ownnode_modules/.pnpm/<pkg>@<ver>directory, so the
script reported packages as missing while leaving stale links in place. Each
peer is now located independently (scripts/link-dsh.mjs).- The suite leaked a temporary directory per
mkdtempSynccall, roughly thirty
per run, and never removed any of them. A throwawayDSH_HOMEis still created
per suite, but it is now registered withtest/helpers/tmp.tsand removed on
process exit, including when an assertion fails. The two measurement scripts
clean up after themselves the same way. - The repository could not be installed by anyone. pnpm 12 refuses to install a
package published within the last 24 hours, and the@deepseek-ai/*packages
this plugin is built against are typically hours old, sopnpm installfailed
on a fresh clone — locally, in CI, everywhere — until the versions happened to
age past the window. The committedpnpm-workspace.yamlexcludes that scope
from the age check and nothing else. - A first attempt at the same problem pinned the four direct
@deepseek-ai/*
packages to exact versions, which made things worse rather than better: the
packages pinned, the peers they pull in —dsh-agent,dsh-session,...