Skip to content

Add stateless Streamable HTTP transport (MCP 2026-07-28) - #37

Merged
jkudish merged 2 commits into
jkudish:mainfrom
shivasymbl:feat/stateless-http-v2
Sep 26, 2026
Merged

jkudish merged 2 commits into
jkudish:mainfrom
shivasymbl:feat/stateless-http-v2

Conversation

@shivasymbl

Copy link
Copy Markdown
Contributor

Summary

  • jev-mcp --http (or JEV_MCP_TRANSPORT=http) serves the eleven tools over Streamable HTTP with no sessions. It speaks MCP 2026-07-28 per request and serves 2025-era clients (Claude Code, Codex, and others today) through the SDK's stateless fallback, all from one /mcp endpoint. Nothing is held between requests, so replicas scale behind any load balancer without sticky routing. /health is included for platform health checks.
  • JEV_MCP_AUTH_TOKEN gates the endpoint with a bearer token (constant-time compare). Every call spends the operator's Jev key, so the server refuses to start on a non-loopback HOST without a token.
  • Stdio stays the default. Existing 2025-era clients see no change, and stdio now also answers 2026-07-28 clients through serveStdio.

How

Serving 2026-07-28 exists only in the v2 SDK packages. v1.30.x can't serve it, so this moves @modelcontextprotocol/sdk 1.x to @modelcontextprotocol/server + @modelcontextprotocol/node 2.1.0 (and @modelcontextprotocol/client for tests).

To keep the diff small, the tool bodies are untouched. The module-level server.registerTool(...) calls become tools.registerTool(...): they record into a list, and createServer() replays that list onto a fresh McpServer per stdio connection or HTTP request. That factory is the shape both createMcpHandler and serveStdio expect. The only other change inside the tools is extra.signal → ctx.mcpReq.signal, the v2 name, so cancellation is wired exactly as before. The HTTP wiring lives in a new src/http.ts (about 50 lines, node:http only, no new framework dependency).

Prior art

#30 (OtisRed) added a sessionful Streamable HTTP mode on SDK v1 and was closed by its author for a local pilot. This PR takes the stateless route instead. That's the direction the 2026-07-28 revision standardizes: Mcp-Session-Id and the initialize handshake are gone (SEP-2567 / SEP-2575). It also means there's no session expiry, stale-session error handling, or per-session transport bookkeeping to maintain.

Verification

  • npm run typecheck, npm run build
  • npm test: 226 passed, 0 failed. That's the existing 224, with the two cancellation tests updated for v2's callTool(params, options) signature, plus a new test/http.test.mjs:
    • a non-loopback bind without a token exits non-zero
    • /health is 200, and /mcp without a token is 401
    • a default (2025-era) v2 Client and a client pinned to 2026-07-28 both list all 11 tools, and report legacy and modern eras respectively
  • Manual: in a node:22-slim container, curl tools/list returns all 11 tools in both eras: 2025 with no initialize and no Mcp-Session-Id in the response, and 2026-07-28 with the _meta envelope. A stdio client pinned to 2026-07-28 negotiates modern and lists all 11.

Not run: npm run test:e2e (needs a live key).

Scope

No changes to tool semantics, providers, inputs, or outputs, and no new runtime dependencies beyond the SDK package split. README gets a short "Remote / HTTP" section, and the CHANGELOG gets an Unreleased entry.

🤖 Generated with Claude Code

shivasymbl and others added 2 commits September 26, 2026 09:09
`jev-mcp --http` (or JEV_MCP_TRANSPORT=http) serves the tools over
Streamable HTTP with no sessions: MCP 2026-07-28 per request, and
2025-era clients through the SDK's stateless fallback, from one endpoint.
A bearer token (JEV_MCP_AUTH_TOKEN) is required unless HOST is loopback,
since every call spends the operator's Jev key. Stdio stays the default.

Moves from @modelcontextprotocol/sdk 1.x to the v2 packages, which is
where 2026-07-28 serving lives. Tools are registered once at module scope
and replayed onto a fresh McpServer per stdio connection or HTTP request,
so the tool bodies are untouched apart from extra.signal -> ctx.mcpReq.signal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A loopback bind may run without JEV_MCP_AUTH_TOKEN, which left it open to
DNS rebinding: a web page could resolve its own hostname to 127.0.0.1 and
spend the operator's Jev key. Apply the SDK's localhost Host/Origin guards
on loopback binds, as the Streamable HTTP spec requires, with a regression
test that fails without them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jkudish
jkudish merged commit 833d41a into jkudish:main Sep 26, 2026

jkudish commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner
  • Thanks for this — clean implementation of the stateless core, and the dual-era fallback is exactly the right shape.
  • Merged with hardening on top: loopback-by-default bind, concurrency cap with 429 backpressure, fixed-string client errors, and abort-aware regex workers.
  • Stdio stays the default; --http is opt-in.
  • Shipping in v0.10.0.

@shivasymbl

Copy link
Copy Markdown
Contributor Author

Thank you

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