Skip to content

feat(transport): add Streamable HTTP transport via --http flag#62

Open
robertlestak wants to merge 6 commits into
dominik1001:mainfrom
robertlestak:streamable-http-v2
Open

feat(transport): add Streamable HTTP transport via --http flag#62
robertlestak wants to merge 6 commits into
dominik1001:mainfrom
robertlestak:streamable-http-v2

Conversation

@robertlestak

@robertlestak robertlestak commented May 19, 2026

Copy link
Copy Markdown

Summary

Adds an opt-in Streamable HTTP transport selected with a --http CLI flag. Default stdio behavior is unchanged.

  • New transport path uses the MCP SDK's StreamableHTTPServerTransport in stateless mode, exposing a single POST /mcp endpoint built on Node's stdlib node:httpno new runtime dependencies.
  • Bind address and port are configurable via LISTEN_ADDR (default localhost) and PORT (default 3000).
  • Adds a Dockerfile + .dockerignore for 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 broken src/index.test.ts's "no console output" assertion).
  • "Do we really need full-blown express?" — no. Replaced with node:http. The new diff adds zero dependencies to package.json.
  • "Could you explain in the README what the Dockerfile is for?" — added a ## Docker section explaining when to use it (running the HTTP transport as a long-running container) and how, alongside a new ## Transports section 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 locally
  • Existing src/index.test.ts still asserts zero console output on the default stdio path
  • Manual: end-to-end round-trip against a real CalDAV server (Radicale). The container built from this branch's Dockerfile is deployed and registered with a Claude Code session as an MCP server; both list-calendars (success path) and list-events (success path, plus structured error propagation on a backend 500) round-tripped cleanly over POST /mcp.
  • Manual: docker build -t caldav-mcp . — image builds clean (260MB on node:20-alpine); the deployed instance above is the same image.

robertlestak and others added 4 commits May 18, 2026 19:31
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 dominik1001 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

Comment thread src/index.ts
res.end(JSON.stringify({ error: "not found" }));
return;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

Comment thread src/index.ts
req.url || "/",
`http://${req.headers.host || "localhost"}`,
);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

Comment thread src/index.ts Outdated
});
// Cast works around SDK type drift under exactOptionalPropertyTypes:
// the transport class's onclose getter is `(() => void) | undefined`
// while the Transport interface declares `onclose?: () => void`.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

Comment thread src/index.ts
// 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,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

Comment thread .dockerignore
.git
.github
.env
*.md

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

*.md excludes LICENSE.md (if it exists) from the image. For compliance, consider narrowing to specific files or keeping LICENSE*.

@dominik1001 dominik1001 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

See inline comments

dominik1001 and others added 2 commits May 27, 2026 12:10
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.
@robertlestak

Copy link
Copy Markdown
Author

Thanks for the review — all points addressed in 69d1505.

Auth (blocking): POST /mcp is now gated behind MCP_API_KEY. When set, requests must send Authorization: Bearer <token>; mismatches get 401 + WWW-Authenticate: Bearer. Comparison uses crypto.timingSafeEqual (length-checked first) so it doesn't leak the key. When unset, startup logs a warning that the endpoint is unauthenticated rather than failing — keeps localhost dev frictionless. Documented in README, .env.example, and the Docker run example.

Repeated server.connect(): Good catch — this was a real bug, not just a leak risk. The SDK's Protocol holds a single active _transport, so a shared McpServer would cross-route responses under concurrent stateless requests. Fixed by building a fresh McpServer per request via a buildServer() factory (also used by the stdio path). To avoid hitting CalDAV on every request, the startup getCalendars() result is reused as the list-calendars snapshot.

Route handling / 405: /mcp now routes all methods through transport.handleRequest(), so the SDK returns 405 with Allow: GET, POST, DELETE for unsupported methods. 404 is reserved for non-/mcp paths — clients can now distinguish wrong URL from unsupported method.

Graceful shutdown: httpServer is captured and closed on SIGINT/SIGTERM.

Type cast: Still required under exactOptionalPropertyTypes with @modelcontextprotocol/sdk@1.29.0 (the transport class's onclose getter is (() => void) | undefined vs. the interface's onclose?: () => void). Replaced the inline note with a TODO(@modelcontextprotocol/sdk@1.29.0) tracking comment so it gets revisited on the next SDK bump.

.dockerignore: Added !LICENSE* so license files stay in the image.

npm run validate (check + test + knip + docs:check + build) passes.

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