feat(transport): add Streamable HTTP transport via --http flag#62
feat(transport): add Streamable HTTP transport via --http flag#62robertlestak wants to merge 6 commits into
Conversation
Selects transport at startup. Without flags, stdio behavior is unchanged. With --http, listens on POST /mcp using the MCP SDK's StreamableHTTPServerTransport in stateless mode. Uses the Node stdlib http module (no new runtime dependencies). Bind address and port are configurable via LISTEN_ADDR (default localhost) and PORT (default 3000). Adds a Dockerfile and .dockerignore for running the HTTP transport in a container, with usage documented in README alongside the new transport section.
CI runs Node 22 (npm 10), which rejected the npm 11-generated lockfile for missing transitive @emnapi/core and @emnapi/runtime entries under @napi-rs/wasm-runtime. Regenerated in place so both npm 10 and 11 accept it.
dominik1001
left a comment
There was a problem hiding this comment.
Nice work — zero new deps, stateless mode, enableJsonResponse, and the Docker setup are all the right calls. A few things to address before merge:
Auth middleware (blocking)
The HTTP endpoint is completely open. Anyone who can reach the port gets full calendar read/write access. At minimum, gate requests behind a bearer token checked against an env var (e.g. MCP_API_KEY). The SDK's client-side AuthProvider already expects Authorization: Bearer <token> — this would plug right in.
Smaller items
See inline comments for: route handling, graceful shutdown, the type cast, and .dockerignore.
| res.end(JSON.stringify({ error: "not found" })); | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
Every request creates a new transport and calls server.connect(). The SDK stateless examples show this pattern, but it's worth verifying under load that this doesn't leak event listeners or state on the McpServer instance — connect() may not be designed for repeated calls on the same server.
| req.url || "/", | ||
| `http://${req.headers.host || "localhost"}`, | ||
| ); | ||
|
|
There was a problem hiding this comment.
Currently returns 404 for GET/DELETE on /mcp. Per the MCP spec, stateless servers should return 405 for unsupported methods on a valid endpoint (so clients can distinguish "wrong URL" from "method not supported").
You could simplify this by routing all methods on /mcp through transport.handleRequest() and letting the SDK return the correct status codes, and only 404 for other paths.
| }); | ||
| // Cast works around SDK type drift under exactOptionalPropertyTypes: | ||
| // the transport class's onclose getter is `(() => void) | undefined` | ||
| // while the Transport interface declares `onclose?: () => void`. |
There was a problem hiding this comment.
The httpServer handle isn't captured for SIGINT cleanup. The SDK docs recommend closing all transports on shutdown:
process.on('SIGINT', async () => {
httpServer.close();
process.exit(0);
});Since this is stateless the impact is lower, but open connections will be hard-killed without it.
| // enableJsonResponse=true returns a single JSON response instead of SSE, | ||
| // which matches the request/response shape for one-shot tool calls. | ||
| const transport = new StreamableHTTPServerTransport({ | ||
| enableJsonResponse: true, |
There was a problem hiding this comment.
The as unknown as cast to work around exactOptionalPropertyTypes drift should be tracked — either file an SDK issue or note the SDK version so this can be removed when fixed. Silent casts on transport types could mask real type errors down the road.
| .git | ||
| .github | ||
| .env | ||
| *.md |
There was a problem hiding this comment.
*.md excludes LICENSE.md (if it exists) from the image. For compliance, consider narrowing to specific files or keeping LICENSE*.
Address PR review feedback: - Gate POST /mcp behind MCP_API_KEY bearer token (timingSafeEqual, 401 + WWW-Authenticate on failure); warn when unset that the endpoint is unauthenticated. - Build a fresh McpServer per request: the SDK Protocol holds a single active transport, so a shared server cross-routes responses under concurrent stateless requests. Reuse the startup getCalendars() result as the list-calendars snapshot to avoid per-request CalDAV calls. - Route all methods on /mcp through transport.handleRequest so the SDK returns 405 (Allow: GET, POST, DELETE) for unsupported methods; 404 only for other paths. - Close httpServer on SIGINT/SIGTERM for graceful shutdown. - Note SDK 1.29.0 type drift on the connect() cast with a tracking TODO. - Keep LICENSE* in the Docker image (.dockerignore). - Document MCP_API_KEY in README and .env.example.
|
Thanks for the review — all points addressed in 69d1505. Auth (blocking): Repeated Route handling / 405: Graceful shutdown: Type cast: Still required under
|
Summary
Adds an opt-in Streamable HTTP transport selected with a
--httpCLI flag. Defaultstdiobehavior is unchanged.StreamableHTTPServerTransportin stateless mode, exposing a singlePOST /mcpendpoint built on Node's stdlibnode:http— no new runtime dependencies.LISTEN_ADDR(defaultlocalhost) andPORT(default3000).Dockerfile+.dockerignorefor running the HTTP transport as a container, with usage documented in README.Context
This is a clean reroll of #7, which was closed after I went unresponsive - apologies for the delay in getting back, had some other projects at work take prio :). It addresses each piece of review feedback from that PR:
console.error("CalDAV Config:", ...)debug log — removed (it was a leftover and would have brokensrc/index.test.ts's "no console output" assertion).node:http. The new diff adds zero dependencies topackage.json.## Dockersection explaining when to use it (running the HTTP transport as a long-running container) and how, alongside a new## Transportssection documenting both modes.The branch is rebuilt cleanly on top of current
main(biome formatting, strict tsconfig, etc.) rather than rebased from the old PR branch.Test plan
npm run validate(check + test + knip + docs:check + build) passes locallysrc/index.test.tsstill asserts zero console output on the default stdio pathDockerfileis deployed and registered with a Claude Code session as an MCP server; bothlist-calendars(success path) andlist-events(success path, plus structured error propagation on a backend 500) round-tripped cleanly overPOST /mcp.docker build -t caldav-mcp .— image builds clean (260MB onnode:20-alpine); the deployed instance above is the same image.