Skip to content

fix(http): create MCP transport per session/request, not one shared instance - #68

Merged
howethomas merged 1 commit into
mainfrom
claude/upbeat-elgamal-ee0721
Aug 17, 2026
Merged

fix(http): create MCP transport per session/request, not one shared instance#68
howethomas merged 1 commit into
mainfrom
claude/upbeat-elgamal-ee0721

Conversation

@howethomas

Copy link
Copy Markdown
Contributor

Problem

The MCP-over-HTTP endpoint accepted exactly one request per container start. Verified against a live deployment (1.3.0, MCP_TRANSPORT=http, MCP_HTTP_STATELESS=true): first initialize returned 200 with a correct envelope, every subsequent request returned an empty-body HTTP 500. tools/list as a separate POST also 500'd. REST under /api/v1 was unaffected because it is routed to Koa before the MCP fall-through.

Root cause

startHttpServer created one StreamableHTTPServerTransport at startup and passed that same instance to setupHttpMiddleware for every request. The SDK rejects this outright (webStandardStreamableHttp.js):

if (!this.sessionIdGenerator && this._hasHandledRequest) {
    throw new Error('Stateless transport cannot be reused across requests. Create a new transport per request.');
}

setupHttpMiddleware called transport.handleRequest(req, res) without awaiting, so that rejection never became a response — hence the empty body behind the 500.

Stateful mode was broken too, differently: a second client's initialize got 400 Invalid Request: Server already initialized, and sessions would have collided between clients anyway.

Reproduced both against the old shape:

mode request 1 request 2
stateless 200 500, empty body
stateless + enableJsonResponse 200 500, empty body
stateful 200 400 Server already initialized

Fix

  • startHttpServer now takes a () => Server factory instead of a pre-built server + transport. An MCP Server binds to exactly one transport, so HTTP needs one per session (stateful) / per request (stateless).
  • Each request reuses the transport registered for its Mcp-Session-Id, or gets a fresh transport plus a fresh Server. Stateful sessions are tracked in a map keyed by session id, populated from onsessioninitialized and dropped on onclose.
  • Stateless transports are closed when the response ends.
  • setupHttpMiddleware returns the handleRequest promise, so a failure becomes a JSON-RPC 500 body instead of a dead socket.
  • Drive-by: config.port || parseInt(...) treated port 0 (bind any free port) as unset and fell through to 3000. Now ??.

Notes on the target design

MCP_HTTP_JSON_ONLY / enableJsonResponse makes no difference to any of this — the reuse guard runs before response shaping. The regression tests are parametrized over both values to keep that documented.

MCP spec revision 2026-07-28 (SEP-2567/SEP-2575) removes sessions and the handshake entirely, but the pinned SDK (1.26.0) still tops out at protocol 2025-11-25 and has no sessionless path, so the session-aware design is still required today. Per-request transport is what that spec makes mandatory anyway, so this moves toward it rather than away.

Tests

New tests/transport/http-lifecycle.test.ts: two full client handshakes in sequence against one running server, tools/list as a separate request after initialize, and two concurrent clients — each across stateless/stateful × jsonOnly on/off (12 tests). Full suite: 820 passed.

🤖 Generated with Claude Code

…nstance

startHttpServer built a single StreamableHTTPServerTransport at startup and
handed it to every request. In stateless mode the SDK throws "Stateless
transport cannot be reused across requests" on request two, and because
setupHttpMiddleware never awaited handleRequest the rejection surfaced as an
empty-body HTTP 500 -- so a hosted server accepted exactly one `initialize`
per container start. Stateful mode was broken too, differently: the second
client got 400 "Server already initialized".

Now each request either reuses the transport for its Mcp-Session-Id or gets a
fresh transport plus a fresh MCP Server, and stateless transports are closed
when the response ends. setupHttpMiddleware returns the handleRequest promise
so failures become a JSON-RPC 500 instead of a dead socket.

Also fixes port resolution: `config.port || ...` treated port 0 (bind any
free port) as unset and fell through to 3000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@howethomas
howethomas merged commit b04c169 into main Aug 17, 2026
4 checks passed
@howethomas
howethomas deleted the claude/upbeat-elgamal-ee0721 branch August 17, 2026 21:47
howethomas added a commit that referenced this pull request Aug 18, 2026
Minor, not patch: PR #65 adds a new embedding provider, a new edge function
and a migration, which is backwards-compatible new functionality.

Ships three things that were unreleased on main:

- Supabase-native gte-small embeddings, so a dataset needs no third-party
  embedding API key anywhere in the stack (#65)
- MCP over HTTP works past the first request. One transport instance was
  shared across all requests, so a hosted server accepted exactly one
  `initialize` per container start and then returned empty-body 500s (#68)
- Attachment purpose='tags' is honoured, and the vcon_embeddings upsert is
  idempotent. Both failed silently before: tags in a spec-correct 0.4.0
  document were unreachable, and every backfill pass duplicated vectors (#67, #65)

Supersedes the abandoned 1.3.1 release commit on claude/peaceful-nobel, which
covered only the tags fix. Also gitignores .omc/ and .claude/worktrees/, agent
session state that was swept into a commit once already.

820 tests pass, 14 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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