feat(mcp): ship @askalf/strongroom-mcp — first-class MCP control-plane server#51
Conversation
…e server Promote examples/mcp-strongroom into an installable package: an MCP server that IS the strongroom control plane. It owns the encrypted vault and runs the egress broker in-process; its tools mint scoped, expiring, revocable leases and hand agents a lease-backed base URL — so API keys never enter agent context. Tools: grant_lease (returns a capability, never a key), list_secrets (names only), list_leases (fingerprints), revoke_lease, broker_status. Deliberately no add_secret/redeem tool — no secret value ever crosses the MCP wire; the vault is populated out-of-band via the strongroom CLI. House MCP recipe: zod RAW shape inputSchemas; @modelcontextprotocol/sdk + zod + @askalf/strongroom as REGULAR deps; stderr-only logging (stdout is the MCP transport). Smoke test spawns the server as a real MCP child over stdio, exercises every tool, and proves the secret value crosses the wire in NO frame. CI gains an mcp job (ubuntu+windows, node 20/22); publish-mcp.yml is a separate OIDC trusted-publisher filename (operator-gated). Ref: 01KXPDGN4NRV0AGKX5YVVN3CB0
…kage node --test globs recursively and picked up mcp/server.test.mjs, which imports @askalf/strongroom (the sibling package the mcp job installs separately). The root has no such node_modules entry — it IS that package — so it failed with ERR_MODULE_NOT_FOUND. Scope discovery to test/; the mcp job runs the mcp test in its own working directory. Ref: 01KXPDGN4NRV0AGKX5YVVN3CB0
…ps it node --test's default file pattern matches *.test.mjs and files under test/. The root runner picked up mcp/server.test.mjs (which imports the sibling @askalf/strongroom that only the mcp job installs). Scoping the root to test/ broke on Node 22, which reads a bare 'test/' positional as a module path, not a directory. Cleanest cross-version + cross-platform fix: rename the mcp test to mcp/smoke.mjs (matches neither default pattern) and have the mcp package's own test script name it explicitly. Root test script returns to plain node --test. Ref: 01KXPDGN4NRV0AGKX5YVVN3CB0
sprayberry-reviewer
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer (sprayberry-reviewer bot). This is a verdict-bearing automated review — not a human sign-off.
Verdict: No blocking issues found. Clean ship.
What I checked
Reviewed the 6 changed files against master: mcp/server.mjs, mcp/smoke.mjs, mcp/package.json, mcp/README.md, .github/workflows/ci.yml, .github/workflows/publish-mcp.yml.
Confirmed:
-
No-key-crossing-wire invariant holds end-to-end. The
grant_leasetool returns abase_urlcapability (never a secret value). Thesecretfield in the response is the input name the caller already supplied — not the stored value.list_secretsreturns names only.list_leasesreturns fingerprints (truncated SHA-256 of the lease id), never raw ids or values. The smoke test (smoke.mjs) verifies this by assertingREAL_KEYappears in zero captured JSON-RPC frames across all directions. -
Tool registration follows the house recipe. All five tools (
grant_lease,list_secrets,list_leases,revoke_lease,broker_status) use the Zod raw shapeinputSchema(notz.object()). All logging goes to stderr; stdout carries only JSON-RPC frames.@modelcontextprotocol/sdk,zod, and@askalf/strongroomare listed as regulardependencies, notdevDependencies. -
revoke_leaseURL parsing is correct. The id-extraction logic (split('/').filter(Boolean).pop()) handles the expected formats cleanly: raw id, full base URL, and trailing-slash URL all resolve correctly to the lease id. Passing a root-only URL (http://host:port/) would pophost:port, whichrevoke()would treat as an unknown id and return{ revoked: false }— incorrect but safe, not a security hole. -
Workflow SHA pins verified. Both
ci.ymlandpublish-mcp.ymluse SHA-pinned action refs:actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
-
Publish workflow uses OIDC; no
NPM_TOKENsecret.id-token: writeat the job level,registry-url: https://registry.npmjs.orgon thesetup-nodestep — standard OIDC trusted-publisher flow. The idempotency gate (npm view ... version) is correct and handles the "already published" case cleanly. -
CI
mcpjob matrix (ubuntu + windows, Node 20 + 22) is correct. Usesnpm install(notnpm ci) for the subpackage, since no lockfile is committed — that's the right call here. -
Broker broker-port initialization is safe in practice. The
brokerPortvariable startsnulland is set in the'listening'event.brokerBase()falls back to the configuredBROKER_PORTwhilebrokerPortis null. In thePORT=0(ephemeral) case there is a theoretical window wherebrokerBase()returnshttp://127.0.0.1:0, but the async gap betweenstartBroker()and a client's first tool call (minimum two MCP protocol roundtrips) is far wider than the OSlisten(0)latency. The smoke test passes on both ubuntu and windows withSTRONGROOM_BROKER_PORT=0confirming this. -
Smoke test
broker_statusassertion confirms listening broker.assert.equal(s.listening, true)andassert.match(s.broker_base, /^http:\/\/127\.0\.0\.1:\d+$/)verify the broker is up and reporting a real port before any lease URL is trusted.
Minor (non-blocking)
STRONGROOM_BROKER_PORTvalidation accepts""→ port0and"-1"→ port-1(server.mjs:37).Number.isFinite(Number(''))istrue(evaluates to0);Number.isFinite(Number('-1'))is alsotrue. Port0re-binds on ephemeral (usually fine), port-1would causelistento throw and the broker to emit an'error'event — which is handled by logging, not crashing the MCP server. In practice no operator setsSTRONGROOM_BROKER_PORT=-1, but a stricter parse (parseInt(v, 10)checked against> 0 && < 65536) would be more defensive. Not blocking.
What's good
The core security invariant — capability-not-credential across the MCP wire — is correctly implemented and verifiably tested with the full-frame capture in smoke.mjs. The broker stays in-process, binds to loopback only, and the design deliberately has no add_secret or redeem tool surface. The separate publish-mcp.yml with its own OIDC binding is the right pattern for a per-package trusted publisher. Code is clean, well-commented, and follows the established conventions throughout the repository.
What
Promotes
examples/mcp-strongroominto an installable package:@askalf/strongroom-mcp— an MCP server that is the strongroom control plane. It owns the encrypted vault and runs the egress broker in-process; its tools mint scoped, expiring, revocable leases and hand agents a lease-backed base URL — so API keys never enter agent context. This is the "next" announced in sprayberrylabs.com/blog/after-zero-raw-credentials.Design
The MCP server is the control plane: it holds the vault + runs the broker. An agent calls
grant_leaseand gets back a capability (base URL), points its HTTP client at it, and calls the upstream with no key — the broker injects the real secret at the network boundary.add_secret/redeemtool — no secret value ever crosses the MCP wire in either direction. The vault is populated out-of-band via thestrongroomCLI.grant_lease(returns a capability, never a key),list_secrets(names only),list_leases(fingerprints),revoke_lease,broker_status.House MCP recipe (followed)
inputSchema(notz.object())@modelcontextprotocol/sdk+zod+@askalf/strongroomas regular deps (not dev)Files
mcp/server.mjs— the MCP server (5 tools, in-process broker)mcp/server.test.mjs— smoke test: spawns the server as a real MCP child over stdio, exercises every tool, and asserts the secret value crosses the wire in no framemcp/package.json—bin: strongroom-mcp, sdk/zod/strongroom as regular depsmcp/README.md— install + MCP client config + tool table.github/workflows/ci.yml— adds anmcpjob (ubuntu+windows, node 20/22).github/workflows/publish-mcp.yml— separate OIDC trusted-publisher filename (per-package TP binding)Test plan
Verified locally on Node 24: 5/5 pass,
node --testexits 0 cleanly (the exact CI command). Thegrant_leasetest proves the real key appears in no captured JSON-RPC frame.Operator follow-up (gated)
npm trusted publishing needs one operator click to bind
@askalf/strongroom-mcp's trusted publisher topublish-mcp.yml(1 TP per package, per filename). Publish is manualworkflow_dispatchand idempotent.Ref: ticket
01KXPDGN4NRV0AGKX5YVVN3CB0