Skip to content

feat(mcp-server): optional stdio-bridge backend + Smithery card (PR 3 of 3)#41

Merged
Declade merged 4 commits into
mainfrom
feat/mcp-server-streamable-http-backend
May 6, 2026
Merged

feat(mcp-server): optional stdio-bridge backend + Smithery card (PR 3 of 3)#41
Declade merged 4 commits into
mainfrom
feat/mcp-server-streamable-http-backend

Conversation

@Declade
Copy link
Copy Markdown
Owner

@Declade Declade commented May 6, 2026

Summary

Third and final PR in the Streamable-HTTP MCP transport workstream (after gateway PR #135 + the still-pending OAuth PR). Adds an opt-in LUCAIRN_TRANSPORT=stdio-bridge mode that turns @lucairn/mcp-server into a thin stdio↔HTTP bridge against the gateway's new streamable-HTTP MCP endpoint at POST /mcp.

Default mode stays direct-http and is bit-identical to v1.1.x — upgrading is non-breaking.

What changed

  • LUCAIRN_TRANSPORT env vardirect-http (default) | stdio-bridge. Any other value exits non-zero at startup with a clear error naming the supported set.
  • src/bridge.ts (new, ~290 LOC) — reads JSON-RPC 2.0 frames from stdio via the MCP SDK's StdioServerTransport, forwards each frame as POST {baseUrl}/mcp with Authorization: Bearer lcr_live_*, writes the gateway's reply back to stdout. Per-request X-Upstream-Key selection (pickUpstreamKeyForFrame) mirrors direct-http mode's GatewayClient.pickUpstreamKey for tools/call frames. Handles JSON-RPC notifications (gateway 204 ack → no stdout write), batches, malformed bodies, network errors (-32603), and stdin parse errors (-32700).
  • mcp-server/smithery.yaml (new) — startCommand.type: http + url: https://gateway.lucairn.eu/mcp for Smithery URL-based publishing, with configSchema exposing lucairnApiKey (required) + anthropicApiKey / openaiApiKey (optional BYOK). One TODO Marc: comment for the icon URL.
  • README.md — new "Transport modes" + "Smithery (URL-based publishing)" sections; lucairn-sdks issue-tracker URL; streaming caveat updated for v1.2.
  • package.json — version bump 1.1.01.2.0.
  • tests/bridge.test.ts (new) — 30 unit tests with HTTP fully mocked. Covers env-var validation, JSON-RPC frame round-trip, BYOK selection across model prefixes, network/timeout/parse-error envelopes, 204 ack handling, the http://-non-loopback guard, batch replies, and empty-body rejection.

Backward-compat

v1.1.x v1.2.0
LUCAIRN_TRANSPORT unset direct-http behavior direct-http behavior (bit-identical)
LUCAIRN_TRANSPORT=direct-http n/a direct-http behavior (bit-identical)
LUCAIRN_TRANSPORT=stdio-bridge n/a new — bridges to POST /mcp
LUCAIRN_TRANSPORT=anything-else n/a exit 1, clear error

The 43 pre-existing tests in tests/server.test.ts + tests/gateway-client.test.ts are untouched and pass on this branch.

Why

Gateway PR #135 (merged 2026-05-06) shipped the streamable-HTTP MCP endpoint at /mcp. The npm package needs an opt-in path to use it so that:

  1. New tools added server-side land for stdio-only clients (Claude Code CLI) without re-publishing the npm package.
  2. Tier-aware tool descriptors come from the gateway's auth-resolved profile — single source of truth.
  3. Smithery URL-based publishing has a documented npm fallback path for clients that need stdio.

Out of scope (per spec)

  • Server-initiated SSE messages from GET /mcp — gateway PR 1 stubs that channel; bridging it is a future workstream.
  • OAuth client-credentials on the npm side — gateway PR 2 covers OAuth at the gateway+website layer.
  • Multi-tool catalogs.
  • Glama / mcp.so listing refresh (Marc-action; out of code scope).
  • npm publish — Marc-action via mcp/v1.2.0 git tag triggering publish-mcp-server.yml.

Cross-links

Test plan

  • npm ci clean
  • npm run typecheck clean (tsc -p tsconfig.test.json)
  • npm run build clean (production tsc)
  • npm test 73/73 pass (43 pre-existing + 30 new) — all HTTP mocked, no real-gateway hits
  • Banned-literal sweep clean (no Pro+ / pro_plus / Solo Free / Solo Pro / ISO 42001 / SOC 2 / HIPAA in the changed surface; legacy veil_live_* references are explicitly framed as "legacy" per CLAUDE.md historical-context allowance)
  • Marc: before tagging mcp/v1.2.0, fill the TODO Marc: icon URL in mcp-server/smithery.yaml (1 line)
  • Marc: Smithery listing refresh after publish (smithery.ai/new with gateway URL OR smithery mcp publish)
  • Marc: end-to-end smoke against gateway.lucairn.eu/mcp with LUCAIRN_TRANSPORT=stdio-bridge once the npm package is republished

🤖 Generated with Claude Code

Declade and others added 4 commits May 6, 2026 16:50
… of 3)

Adds an opt-in `LUCAIRN_TRANSPORT=stdio-bridge` mode that turns
`@lucairn/mcp-server` into a thin stdio<->HTTP bridge against the
gateway's streamable-HTTP MCP endpoint at `POST /mcp` (live since
2026-05-06 via dual-sandbox-architecture PR #135). Default mode stays
`direct-http`, bit-identical to v1.1.x.

What:
- New `LUCAIRN_TRANSPORT` env var: `direct-http` (default) | `stdio-bridge`.
  Any other value exits non-zero with a clear error naming the supported set.
- New `src/bridge.ts` reads JSON-RPC frames from stdio (via the SDK's
  `StdioServerTransport`), forwards each frame as `POST {baseUrl}/mcp`
  with `Authorization: Bearer lcr_live_*`, and writes the gateway's
  reply back to stdout. Per-request `X-Upstream-Key` selection mirrors
  the direct-http path's `pickUpstreamKey` for `tools/call` frames.
- New `mcp-server/smithery.yaml` declaring the public gateway URL
  (`startCommand.type: http`, `url: https://gateway.lucairn.eu/mcp`) for
  Smithery URL-based publishing. One TODO comment for the icon URL
  Marc owns.
- README.md updated: two-mode "Transport modes" section, Smithery
  install snippet, lucairn-sdks issue tracker URL.
- 30 new unit tests (in `tests/bridge.test.ts`) cover env-var
  validation, JSON-RPC frame round-trip, BYOK key selection, network
  + timeout + parse-error envelopes, 204 ack handling, and the
  http://-non-loopback guard. All HTTP is mocked.

Backward-compat: v1.1 -> v1.2 is non-breaking. `direct-http` mode is
the default; users who don't set `LUCAIRN_TRANSPORT` see no change.

Out of scope (per spec):
- Server-initiated SSE messages from `GET /mcp` (gateway PR 1 stubs
  this; bridging is a future workstream).
- OAuth client-credentials on the npm side (PR 2 covers OAuth at the
  gateway+website layer).
- Multi-tool catalogs.
- Glama / mcp.so listing refresh (Marc-action; out of code scope).
- npm publish (Marc-action via `mcp/v1.2.0` git tag triggering
  `publish-mcp-server.yml`).

Spec: `Opus Advisor/specs/streamable-http-mcp-transport-brief-2026-05-06.md` § 4.
Cross-link: gateway PR #135 (merged 2026-05-06T13:46:38Z).

Verification:
- `npm ci` clean.
- `npm run typecheck` clean.
- `npm run build` clean.
- `npm test` 73/73 pass (43 pre-existing + 30 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (keep bridge alive)

Previously transport.onerror only handled SyntaxError (-32700 Parse error)
and routed every other error through settle(err), which closed the bridge
on the very first malformed frame from a buggy MCP client.

The MCP SDK's stdio ReadBuffer pipeline is JSON.parse → JSONRPCMessageSchema.parse:
- JSON.parse failures surface as SyntaxError → -32700 Parse error.
- Zod schema-validation failures (frame is valid JSON but not a JSON-RPC
  envelope, e.g. {"foo":"bar"}) surface as ZodError → previously fatal.

The fix maps SyntaxError → -32700 and any other frame-shape error → -32600
Invalid Request. In both cases the bridge stays alive: the send is
best-effort (.catch swallows transport-side failures) and there is no
settle() call. Code constants per JSON-RPC 2.0 RFC §5.1.

Regression test asserts:
- {"foo":"bar"}\n through stdin produces a -32600 envelope on stdout.
- A subsequent valid frame still reaches the gateway (fetchSpy called
  once) and its reply is written to stdout — proving the bridge
  survived the bad frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ng path

The smithery.yaml shipped in this package used a `startCommand: { type: http, url }`
shape that does not appear in any verified Smithery source: not in
smithery-ai/smithery-cookbook samples (all use `runtime: container` +
`build: { dockerfile, dockerBuildPath }`), not in the Smithery REST API
OpenAPI spec (URL-only / external publication uses `type: "external"` +
`upstreamUrl`), and not in any Smithery docs reference page (no
`smithery.yaml` schema is documented for URL publication).

The actual official URL-publication paths are:
- web UI at https://smithery.ai/new (paste the URL)
- CLI: `smithery mcp publish "<URL>"`

Neither path reads `smithery.yaml`. Shipping a YAML file in a non-existent
schema is a worse failure mode than not shipping one — the file would
either be silently ignored or rejected by Smithery's validator.

Drop the file entirely. Reframe the README's Smithery section to the
web-UI / CLI publication path, which is documented and works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the stdin 'end' / 'close' handler scheduled settle() via
setImmediate(), assuming fetch promises resolve on the same microtask
tick as the dispatch. They don't: a real fetch's I/O completes on a
later tick than setImmediate, so settle() fired first → transport was
closed → the late-resolving fetch's `await transport.send(reply)` ran
against a closed transport and the reply was silently dropped. The
existing tests passed only because `fetchSpy.mockResolvedValue(...)`
resolves synchronously.

The fix tracks in-flight forwards in a `Set<Promise<void>>` (added on
dispatch, removed in `.finally()`). On stdin EOF / abort, drainAndSettle()
snapshots the set and `Promise.allSettled`-waits before calling settle().

Regression test stalls fetch on a manually-resolved Promise (so its
resolution happens strictly after the bridge has reached its drain
state), then resolves it. Asserts the reply IS on stdout when the
bridge promise resolves — pre-fix this would be empty because the
reply was dropped against a closed transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Declade Declade merged commit 22854cc into main May 6, 2026
10 checks passed
@Declade Declade deleted the feat/mcp-server-streamable-http-backend branch May 6, 2026 16:14
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.

1 participant