Files
rea/docs/specs/agent-environments/http-transport/spec.md
T
Edgar Arakelyanandgensokyo-dev 60bd646478 feat(mcp): add opt-in streamable HTTP transport (#4955)
Add an explicit Streamable HTTP entry for environment/gateway hosts while keeping stdio registrations and default behavior. Preserve the shared provider/session owner across DELETE and replacement; fence and drain tool/completion work before handover or teardown.

Bound HTTP input and plain-response admission, cancel refused bodies, join whole exchanges and asynchronous response cleanup before provider hooks, gate startup readiness, and reject queued HTTP/1.1 pipelining while retaining sequential keep-alive.

Validation: 8 static/build tasks, 83 adapter tests and registry metadata pass. A real installed gh binary produces seven equivalent CLI/stdio/HTTP reports with DELETE/replacement and clean signal shutdown. Broader local native-provider/ownership failures reproduce on clean main and are documented with limitations in the PR.

Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-19 23:58:51 +08:00

4.6 KiB

Streamable HTTP transport for the MCP server

Issue: https://github.com/morluto/rea/issues/4933

Outcome

rea mcp --http [--host H] [--port N] serves the existing MCP tool catalog over the pinned SDK's streamable HTTP transport instead of stdio, so an agent environment runtime or any HTTP MCP client can reach REA on a local URL. The listener is opt-in: bare rea mcp, rea mcp doctor, rea mcp add and every client registration keep stdio.

Requirements

  • Route mcp --http ... and --mcp --http ... to the MCP runtime in the dispatcher without changing bare mcp, mcp doctor or mcp add.
  • Resolve host and port from the flags, then MCP_HOST/MCP_PORT from the environment snapshot, then 127.0.0.1 and port 0; announce the bound URL as one pino JSON line on stderr at every log level and leave stdout unused.
  • Use WebStandardStreamableHTTPServerTransport in stateful mode around one server instance built once, after the listener has bound. DELETE ends the session on its transport without running the instance's shutdown hooks, so the same instance keeps its providers and serves the next client on a fresh transport; the hooks run when the listener closes.
  • Let a new client's initialize replace a session that is still open, closing the earlier transport the same way, so a client that left without DELETE cannot block the listener; the replaced client's later requests are refused (404 while the replacing session is open).
  • Bridge node:http to web Request/Response in src/server/http/, streaming response chunks as they are produced and turning a client disconnect into a request abort and body cancellation.
  • Classify a loopback bind by the actual bound IP (127.0.0.0/8, ::1, or IPv4-mapped loopback), without using the configured DNS name as evidence; validate the Host and Origin headers with the SDK loopback allowlist plus the configured name and the bound address on such binds, and relax both for other binds and log that relaxation. Refuse scoped IPv6 literals before binding, close the listener if its URL cannot be formed, and forward listener errors to onerror only after a successful bind.
  • Let the caller own instance cleanup (closeInstance: false) when its factory owner already closes every instance, as the MCP runtime does.
  • Let only an initialize that carries the streamable headers and passes those checks replace an open session; refuse requests once the slot is closed, join an arming session during shutdown, and keep close stages retryable.
  • End the HTTP listener on SIGINT/SIGTERM only, never on stdin EOF, and exit with the signal status (130/143) after cleanup settles; latch a signal that arrives during startup so it is acted on as soon as the lifecycle owner exists, and exit at once on a second one.
  • Export serveStreamableHttp({ host, port, createMcpServer, routes, logger }) returning { url, close } so an agent environment server can add plain routes beside /mcp without a second bridge.
  • Keep listener failure causes out of stderr behind a fixed message while the typed listen error carries the system cause (such as EADDRINUSE) into the error-level log line.

Acceptance and limits

Adapters tests cover the bridge (method, URL, headers, body, GET without body, incremental streaming, abort and cancel) and the listener (listening line, one factory call across three sessions, replacement of an open session by the next initialize, two initialize requests racing to replace one session while the instance's shutdown hook stays installed, a working tool call after DELETE, the instance's shutdown hook firing only when the listener closes, plain routes, 404, Host refusal, port release, typed bind failure naming EADDRINUSE). The dispatcher test covers the new routes and keeps mcp add --http with the CLI. An mcp-boundary test spawns the built CLI and drives initialize, tools/list, binary_session and DELETE with the SDK streamable HTTP client, then proves binary_session still answers in the next session and in a session that replaced one left open; a process-boundary test asserts 143/130 exits and a released port. Concurrent clients are not supported: the newest initialize owns the server. The non-loopback relaxation is covered only through its pure allowlist helper; no wide bind runs in tests. Response budgets keep their stdio-derived floor; registrations, rea mcp doctor and server.json stay stdio.

Deferred issue criteria

Serving an environment manifest with replayed records, its environment card and the exported bundle are later increments of #4933. This PR must reference rather than close the issue.