Development
September 18, 2026 · View on GitHub
Requirements
- Node.js >= 22.18.0. The test files are TypeScript executed directly by Node's built-in
type stripping, which is on by default from 22.18.0 and stable from 24.12.0. There is no
test transpiler and no
ts-node. - pnpm. Declared as
packageManagerinpackage.json.
Setup
git clone https://github.com/wings1848/dsh-mcp-lazy.git
cd dsh-mcp-lazy
pnpm install
pnpm test
If pnpm test fails with an error about @deepseek-ai/dsh-tools, see
Why link-dsh is mandatory.
Commands
| Command | What it does |
|---|---|
pnpm run build | tsc -p tsconfig.json, emitting lib/ |
pnpm run typecheck | Type-check src/ without emitting |
pnpm run test:types | Type-check src/ and test/ together |
pnpm test | Build, relink the peers, then node --test over test/unit/*.test.ts |
pnpm run check | typecheck, then lint, then build, then test:types — what CI runs before the tests |
pnpm run link-dsh | Symlink the @deepseek-ai/* peers from the running DSH install |
pnpm run measure:surface | Print the constant model-facing cost in bytes and parameters |
pnpm run measure:savings | Compare native registration against the gateway |
test has a pretest hook that builds and relinks, so a fresh checkout can go straight to
pnpm test. There is no separate build step to remember.
The order inside check is not cosmetic. typecheck reads only src/ and needs nothing
built. test:types reads test/ as well, and the tests import ../../lib/*.js — so it needs
lib/ to exist. build therefore has to sit between them. With test:types first, check
passes on any machine where lib/ is already lying around and fails on every fresh clone with
twenty-nine TS2307: Cannot find module '../../lib/...' errors. That is not hypothetical; it
is what CI caught on the first push.
To reproduce a runner locally, delete both generated directories before believing a green run:
rm -rf lib node_modules
pnpm install --frozen-lockfile && pnpm run check && pnpm test
--test-name-pattern is the fast loop while iterating:
node --test --test-name-pattern='M9' "test/unit/connection.e2e.test.ts"
Loading the local build into a dsh profile
link-dsh only symlinks the @deepseek-ai/* peers into this checkout, so the plugin's own
imports resolve to the harness instance. It does not install the plugin into DSH. A profile gets
the plugin from its own node_modules:
$DSH_HOME/profiles/<profile>/node_modules/dsh-mcp-lazy/
That copy is a snapshot. Rebuilding here changes nothing in a running DSH: the loader imports the plugin once, at boot. Two consequences:
-
Point the profile at this checkout while developing:
P=~/.dsh/profiles/web/node_modules/dsh-mcp-lazy mv "$P" "$P.bak-$(date +%Y%m%d-%H%M%S)" # keep the installed copy for rollback ln -s "$PWD" "$P" # the profile now runs the working treeRollback is
rmthe symlink andmvthe backup back, or reinstall withdsh plugin --profile web add dsh-mcp-lazy. -
Restart dsh-web to pick up a new build (
systemctl --user restart dsh-web). Module HMR is off —dsh-base'shmrentry shipsconfig.root: [], so only the profile configuration may reload — and swapping the module directory out from under a running host removes the tool it registered: the plugin'smcptool then answersunknown tool "mcp"until the next restart. Do not replace the directory while a host is serving.
A symlinked checkout also means rm -rf lib (which the clean-environment reproduction above does)
briefly leaves the profile pointing at a plugin with no build output. A running host is unaffected
— it already imported what it needs — but a restart in that window would fail. Rebuild first.
To verify an installed copy without touching the live host, boot a throwaway profile whose
node_modules/dsh-mcp-lazy points at the install, with DSH_HOME in a temp directory:
export DSH_HOME=$(mktemp -d)
mkdir -p "$DSH_HOME/profiles/probe/node_modules"
ln -s ~/.dsh/profiles/web/node_modules/@deepseek-ai "$DSH_HOME/profiles/probe/node_modules/@deepseek-ai"
ln -s ~/.dsh/profiles/web/node_modules/dsh-mcp-lazy "$DSH_HOME/profiles/probe/node_modules/dsh-mcp-lazy"
cat > "$DSH_HOME/profiles/probe/package.json" <<'JSON'
{ "name": "dsh-profile-probe", "private": true, "dependencies": {},
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "dsh-mcp-lazy", "@deepseek-ai/dsh-headless"] } } }
JSON
dsh --profile probe --patch ./probe-servers.yml headless </dev/null # stops on missing credentials
A plugin-load failure prints plugin tree failed to load: … mcp-lazy: … on stderr; a boot that
gets past the plugin and stops on MISSING_CREDENTIAL means the configuration was accepted.
--dump-config is not a substitute — it composes the tree without applying it, so it accepts
configuration the plugin would reject.
Why link-dsh is mandatory
@deepseek-ai/cordis, dsh-tools, dsh-subprocess, and schemastery are peer
dependencies. The harness supplies them at plugin load time from its own installation.
If a private copy also exists under this package's node_modules, the plugin builds tool
definitions with a different dsh-tools instance than the runtime that registers them.
That is a class-identity mismatch: it fails confusingly, or silently drifts a release behind
— the registry's latest tag currently points at 0.0.1-rc.1 while the harness runs
0.1.5-rc.1, so a naive install gets something quite different from what the plugin was
built against.
scripts/link-dsh.mjs closes that gap by symlinking the peers from the running DSH
installation. It runs automatically as part of pretest. It locates the installation from
DSH_INSTALL_ROOT, then BUN_INSTALL, then ~/.bun/install/global/node_modules, then
Node's own resolution — so it works for a global install and for a profile-local one.
If no DSH installation is found it prints a warning and exits 0, so pnpm test still
runs against the registry copies. That is what CI does, where no harness is installed.
Do not pin devDependencies to the harness version. Doing so looks like it buys
consistency and actually costs it: the direct packages get pinned while the peers they
depend on — dsh-agent, dsh-session, dsh-llm, and the rest, which pnpm installs
automatically — keep resolving to the newest prerelease. The result is a lockfile that mixes
two prereleases of the same scope, one of which is one release ahead of what actually runs.
The harness itself is uniformly versioned; the lockfile should be too.
So devDependencies carries ordinary ranges, and the committed pnpm-workspace.yaml
excludes the @deepseek-ai/* scope from pnpm 12's 24-hour minimumReleaseAge rule —
without that, a fresh clone cannot install at all, because a harness release is usually
hours old. Neither of those is what makes a local checkout match the running harness;
link-dsh is, and it runs on every test.
Layout
src/
index.ts the cordis plugin: name, inject, Config, apply()
schema.ts the proxy tool's name, parameters, and defaults
registry.ts server state, the metadata cache, search, invocation routing
connection.ts transports, the connection lifecycle, idle reaping, stderr capture
proxy-tool.ts the single model-facing tool
search-ranking.ts scoring, the coverage gate, and the regex guard
output-guard.ts the output ceiling and spill files
metadata-cache.ts the on-disk catalog cache
direct-tools.ts optional native promotion
naming.ts qualified-name handling
types.ts shared types and the public config shape
test/
unit/*.test.ts executed directly by Node; see below
fixtures/mcp-server.mjs a real MCP server, spawned as a child process
scripts/ link-dsh and the two measurement tools
docs/ configuration, troubleshooting, this file, and the design notes
Two conventions are easy to get wrong:
- Tests import from
../../lib/*.js, not fromsrc/. They exercise the built output, solib/must be current.pretesthandles that; runningnode --testby hand does not. - Relative imports in
src/carry a.jsextension, because the package is ESM undermodule: NodeNext. TypeScript resolves./schema.jstosrc/schema.ts.
Anything under test/ that is not a test file is a different case: Node runs it directly by
stripping types, and it requires the exact extension, so a helper is imported as
../helpers/tmp.ts — not .js, and not without an extension. src/ is compiled, test/ is
not, and the two follow opposite rules for the same syntax.
Tests
test/unit/connection.e2e.test.ts spawns test/fixtures/mcp-server.mjs for real, so its
assertions are about processes rather than mocks. The fixture appends a line to
FIXTURE_START_COUNT on every start, which is how "was a new process spawned" is observed
without guessing from pids. Other knobs — FIXTURE_FAIL, FIXTURE_PID_FILE,
FIXTURE_READY_FILE, FIXTURE_EXIT_AFTER_MS — are documented at the top of the fixture.
Nothing in the suite touches the network, and no test needs a DSH installation.
Two properties the suite deliberately guards:
- Activation is quiet for a default configuration.
plugin-load.test.tsasserts that applying the plugin registers exactly one tool and spawns nothing. Only servers configuredeagerorkeep-aliveare contacted at activation. - The model-facing surface does not move. The proxy tool's name, parameter count, and rendered size are asserted, because they are the plugin's entire value proposition.
Code conventions
Enforced by the compiler, not by review:
strict, plusnoUncheckedIndexedAccess— indexed access yieldsT | undefined.erasableSyntaxOnly— noenum, nonamespacewith runtime code, no constructor parameter properties. Node's type stripper cannot erase them.verbatimModuleSyntax— type-only imports must useimport type.- Relative imports use explicit
.jsextensions.
tsconfig.json deliberately has no paths or baseUrl. Node's test runner ignores
tsconfig.json entirely, so a path mapping would only ever hide a real resolution failure.
Measuring
Both measurement scripts render the same tool definitions the same way — JSON bytes, then four bytes per token — so the ratio is meaningful even though the absolute token count is an estimate.
node scripts/measure-surface.mjs # the constant cost
node scripts/measure-token-savings.mjs # local fixture
node scripts/measure-token-savings.mjs --npx chrome-devtools-mcp@1.6.0
node scripts/measure-token-savings.mjs 3 # three fixture servers
If you change anything the model sees, measure-surface.mjs must still print 1525 bytes and
11 parameters. Any other number is a regression to the plugin's whole reason for existing,
and it needs to be a deliberate decision rather than a side effect.