feat(mcp-server): optional stdio-bridge backend + Smithery card (PR 3 of 3)#41
Merged
Merged
Conversation
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-bridgemode that turns@lucairn/mcp-serverinto a thin stdio↔HTTP bridge against the gateway's new streamable-HTTP MCP endpoint atPOST /mcp.Default mode stays
direct-httpand is bit-identical to v1.1.x — upgrading is non-breaking.What changed
LUCAIRN_TRANSPORTenv var —direct-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'sStdioServerTransport, forwards each frame asPOST {baseUrl}/mcpwithAuthorization: Bearer lcr_live_*, writes the gateway's reply back to stdout. Per-requestX-Upstream-Keyselection (pickUpstreamKeyForFrame) mirrors direct-http mode'sGatewayClient.pickUpstreamKeyfortools/callframes. 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/mcpfor Smithery URL-based publishing, withconfigSchemaexposinglucairnApiKey(required) +anthropicApiKey/openaiApiKey(optional BYOK). OneTODO 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 bump1.1.0→1.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
LUCAIRN_TRANSPORTunsetLUCAIRN_TRANSPORT=direct-httpLUCAIRN_TRANSPORT=stdio-bridgePOST /mcpLUCAIRN_TRANSPORT=anything-elseThe 43 pre-existing tests in
tests/server.test.ts+tests/gateway-client.test.tsare 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:Out of scope (per spec)
GET /mcp— gateway PR 1 stubs that channel; bridging it is a future workstream.mcp/v1.2.0git tag triggeringpublish-mcp-server.yml.Cross-links
Opus Advisor/specs/streamable-http-mcp-transport-brief-2026-05-06.md§ 4https://gateway.lucairn.eu/mcpTest plan
npm cicleannpm run typecheckclean (tsc -p tsconfig.test.json)npm run buildclean (productiontsc)npm test73/73 pass (43 pre-existing + 30 new) — all HTTP mocked, no real-gateway hitsPro+/pro_plus/ Solo Free / Solo Pro / ISO 42001 / SOC 2 / HIPAA in the changed surface; legacyveil_live_*references are explicitly framed as "legacy" per CLAUDE.md historical-context allowance)mcp/v1.2.0, fill theTODO Marc:icon URL inmcp-server/smithery.yaml(1 line)smithery mcp publish)gateway.lucairn.eu/mcpwithLUCAIRN_TRANSPORT=stdio-bridgeonce the npm package is republished🤖 Generated with Claude Code