Skip to content

feat(mcp): ship @askalf/strongroom-mcp — first-class MCP control-plane server#51

Merged
askalf merged 4 commits into
masterfrom
feat/strongroom-mcp-package
Jul 16, 2026
Merged

feat(mcp): ship @askalf/strongroom-mcp — first-class MCP control-plane server#51
askalf merged 4 commits into
masterfrom
feat/strongroom-mcp-package

Conversation

@askalf

@askalf askalf commented Jul 16, 2026

Copy link
Copy Markdown
Owner

What

Promotes examples/mcp-strongroom into 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_lease and 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.

  • No add_secret / redeem tool — no secret value ever crosses the MCP wire in either direction. The vault is populated out-of-band via the strongroom CLI.
  • Tools: grant_lease (returns a capability, never a key), list_secrets (names only), list_leases (fingerprints), revoke_lease, broker_status.

House MCP recipe (followed)

  • zod RAW shape inputSchema (not z.object())
  • @modelcontextprotocol/sdk + zod + @askalf/strongroom as regular deps (not dev)
  • stderr-only logging — stdout is the MCP stdio transport

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 frame
  • mcp/package.jsonbin: strongroom-mcp, sdk/zod/strongroom as regular deps
  • mcp/README.md — install + MCP client config + tool table
  • .github/workflows/ci.yml — adds an mcp job (ubuntu+windows, node 20/22)
  • .github/workflows/publish-mcp.yml — separate OIDC trusted-publisher filename (per-package TP binding)

Test plan

cd mcp && npm install && npm test

Verified locally on Node 24: 5/5 pass, node --test exits 0 cleanly (the exact CI command). The grant_lease test 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 to publish-mcp.yml (1 TP per package, per filename). Publish is manual workflow_dispatch and idempotent.

Ref: ticket 01KXPDGN4NRV0AGKX5YVVN3CB0

askalf added 4 commits July 16, 2026 22:35
…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 sprayberry-reviewer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. No-key-crossing-wire invariant holds end-to-end. The grant_lease tool returns a base_url capability (never a secret value). The secret field in the response is the input name the caller already supplied — not the stored value. list_secrets returns names only. list_leases returns fingerprints (truncated SHA-256 of the lease id), never raw ids or values. The smoke test (smoke.mjs) verifies this by asserting REAL_KEY appears in zero captured JSON-RPC frames across all directions.

  2. Tool registration follows the house recipe. All five tools (grant_lease, list_secrets, list_leases, revoke_lease, broker_status) use the Zod raw shape inputSchema (not z.object()). All logging goes to stderr; stdout carries only JSON-RPC frames. @modelcontextprotocol/sdk, zod, and @askalf/strongroom are listed as regular dependencies, not devDependencies.

  3. revoke_lease URL 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 pop host:port, which revoke() would treat as an unknown id and return { revoked: false } — incorrect but safe, not a security hole.

  4. Workflow SHA pins verified. Both ci.yml and publish-mcp.yml use SHA-pinned action refs:

    • actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
    • actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
  5. Publish workflow uses OIDC; no NPM_TOKEN secret. id-token: write at the job level, registry-url: https://registry.npmjs.org on the setup-node step — standard OIDC trusted-publisher flow. The idempotency gate (npm view ... version) is correct and handles the "already published" case cleanly.

  6. CI mcp job matrix (ubuntu + windows, Node 20 + 22) is correct. Uses npm install (not npm ci) for the subpackage, since no lockfile is committed — that's the right call here.

  7. Broker broker-port initialization is safe in practice. The brokerPort variable starts null and is set in the 'listening' event. brokerBase() falls back to the configured BROKER_PORT while brokerPort is null. In the PORT=0 (ephemeral) case there is a theoretical window where brokerBase() returns http://127.0.0.1:0, but the async gap between startBroker() and a client's first tool call (minimum two MCP protocol roundtrips) is far wider than the OS listen(0) latency. The smoke test passes on both ubuntu and windows with STRONGROOM_BROKER_PORT=0 confirming this.

  8. Smoke test broker_status assertion confirms listening broker. assert.equal(s.listening, true) and assert.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_PORT validation accepts "" → port 0 and "-1" → port -1 (server.mjs:37). Number.isFinite(Number('')) is true (evaluates to 0); Number.isFinite(Number('-1')) is also true. Port 0 re-binds on ephemeral (usually fine), port -1 would cause listen to throw and the broker to emit an 'error' event — which is handled by logging, not crashing the MCP server. In practice no operator sets STRONGROOM_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.

@askalf
askalf merged commit 038104f into master Jul 16, 2026
10 checks passed
@askalf
askalf deleted the feat/strongroom-mcp-package branch July 16, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants