Streamable HTTP Transport

View Source

MCP Streamable HTTP transport for Claude Code (and any other MCP client speaking the same protocol).

Overview

The Streamable HTTP transport implements MCP 2025-11-25 and negotiates downward to 2025-06-18, 2025-03-26, and 2024-11-05 based on the client's initialize request. It is the transport expected by Claude Code's --transport http option.

This transport supports:

  • POST for client requests with JSON or SSE streaming responses.
  • GET for server-to-client notification streams (SSE).
  • DELETE for session termination.
  • OPTIONS for CORS preflight.
  • Session management via Mcp-Session-Id header.
  • Replay on reconnect via Last-Event-ID.
  • Origin validation with operator-controlled allow-list.

The built-in server is built on the h1 and h2 libraries, not Cowboy. A cleartext bind speaks HTTP/1.1; a TLS bind serves both HTTP/1.1 and HTTP/2 on the same port, chosen per connection by ALPN. The protocol logic lives in a transport-neutral engine (barrel_mcp_http_engine), so the same Streamable HTTP behaviour can be driven by another HTTP stack (see Embedding in another HTTP server).

Starting the Server

%% Basic start
barrel_mcp:start_http_stream(#{port => 9090}).

%% With API key authentication
barrel_mcp:start_http_stream(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{
                <<"my-api-key">> => #{subject => <<"user1">>}
            }
        }
    }
}).

%% With session management disabled
barrel_mcp:start_http_stream(#{
    port => 9090,
    session_enabled => false
}).

%% With HTTPS/TLS
barrel_mcp:start_http_stream(#{
    port => 9443,
    ssl => #{
        certfile => "/path/to/cert.pem",
        keyfile => "/path/to/key.pem"
    }
}).

Options

OptionTypeDefaultDescription
portpos_integer()9090Port number.
ipinet:ip_address(){127,0,0,1}Bind address. Default is loopback. Public binds require allowed_origins (see below).
authmap()#{}Authentication configuration.
session_enabledboolean()trueEnable session management.
sslmap()undefinedTLS configuration.
allowed_origins[binary()] | anyloopback set on loopback bind; required on public bindList of allowed Origin values, structurally matched (scheme + host + port). Use any to disable validation. The literal <<"null">> may be included to allow sandboxed-frame origins.
allow_missing_originboolean()true on loopback, false otherwiseWhether to accept requests with no Origin header. Non-browser clients typically don't send one.
sse_buffer_sizepos_integer()256Per-session ring buffer of recent SSE events for Last-Event-ID replay.

Security defaults

The transport binds to 127.0.0.1 by default. Public binds (any non-loopback IP) require an explicit allowed_origins; the start function refuses with {error, allowed_origins_required} otherwise. This avoids accidental exposure to DNS-rebinding and CORS-style attacks.

%% Public bind — must list allowed origins explicitly.
{ok, _} = barrel_mcp:start_http_stream(#{
    port => 9090,
    ip => {0, 0, 0, 0},
    allowed_origins => [<<"https://app.example.com">>]
}).

CORS responses echo the validated Origin (no wildcard) and add Vary: Origin. The Access-Control-Allow-Headers list is derived from the configured auth provider via the optional auth_headers/1 callback on barrel_mcp_auth, so a custom header_name on barrel_mcp_auth_apikey flows through both the preflight allow-list and the request handler's header extraction.

Claude Code Integration

After starting the server, add it to Claude Code:

# Without authentication
claude mcp add my-server --transport http http://localhost:9090/mcp

# With API key authentication
claude mcp add my-server --transport http http://localhost:9090/mcp \
  --header "X-API-Key: my-api-key"

# With bearer token
claude mcp add my-server --transport http http://localhost:9090/mcp \
  --header "Authorization: Bearer my-token"

To verify the connection:

claude mcp list

Authentication

All authentication providers from barrel_mcp are supported:

No Authentication (Default)

barrel_mcp:start_http_stream(#{port => 9090}).

API Key

barrel_mcp:start_http_stream(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{<<"key-123">> => #{subject => <<"user">>}}
        }
    }
}).

Bearer Token (JWT)

barrel_mcp:start_http_stream(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{
            secret => <<"your-jwt-secret">>
        }
    }
}).

Basic Auth

barrel_mcp:start_http_stream(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{<<"admin">> => <<"password">>}
        }
    }
}).

Session Management

When session_enabled is true (default), the server tracks client sessions:

  • Each client receives an Mcp-Session-Id header in responses
  • Sessions expire after 30 minutes of inactivity (configurable via session_ttl env)
  • GET requests open SSE streams for server notifications
  • DELETE requests terminate sessions

Session Lifecycle

  1. First Request: Client sends POST without session ID
  2. Session Created: Server responds with Mcp-Session-Id: mcp_<hex>
  3. Subsequent Requests: Client includes Mcp-Session-Id header
  4. Termination: Client sends DELETE with session ID

Configuring Session TTL

%% In sys.config
{barrel_mcp, [
    {session_ttl, 3600000}  %% 1 hour in milliseconds
]}.

Server-Sent Events (SSE)

Clients that accept text/event-stream can receive streaming responses:

Request Format

POST /mcp HTTP/1.1
Accept: text/event-stream, application/json
Content-Type: application/json
Mcp-Session-Id: mcp_abc123

{"jsonrpc": "2.0", "method": "tools/list", "id": 1}

Response Format

HTTP/1.1 200 OK
Content-Type: text/event-stream
Mcp-Session-Id: mcp_abc123

id: 1706345678901234
data: {"jsonrpc": "2.0", "result": {...}, "id": 1}

HTTPS/TLS

For production deployments, enable HTTPS:

barrel_mcp:start_http_stream(#{
    port => 9443,
    ssl => #{
        certfile => "/path/to/fullchain.pem",
        keyfile => "/path/to/privkey.pem",
        cacertfile => "/path/to/chain.pem"  %% optional
    }
}).

A TLS bind advertises ALPN h2 and http/1.1, so the one port serves HTTP/2 clients and HTTP/1.1 clients alike; certfile/keyfile (and optional cacertfile) are file paths. Then use the HTTPS URL with Claude Code:

claude mcp add my-server --transport http https://my-server.example.com:9443/mcp

CORS and request validation

The server validates Origin on every request method (POST, GET, DELETE, OPTIONS) and replies with 403 on mismatch. When the request's Origin validates, the response includes:

  • Access-Control-Allow-Origin: <validated origin>
  • Vary: Origin
  • Access-Control-Allow-Methods: POST, GET, DELETE, OPTIONS
  • Access-Control-Allow-Headers: content-type, accept, mcp-session-id, mcp-protocol-version, last-event-id plus any auth headers declared by the configured provider.
  • Access-Control-Expose-Headers: www-authenticate, mcp-session-id, mcp-protocol-version

When the request has no Origin header (typical of non-browser clients) and allow_missing_origin is true, the response omits Access-Control-Allow-Origin entirely rather than synthesising a value.

Wire-level conformance

The transport implements MCP 2025-11-25 conformance points explicitly:

WireBehaviour
MCP-Protocol-Version request headerRequired after init. Unsupported value → 400; missing falls back to the session-stored negotiated version; pre-init missing assumes 2025-03-26.
Mcp-Session-Id request headerRequired on every non-initialize request when session_enabled is true. Missing → 400; unknown id → 404. initialize is the only request that creates a session.
Notifications and posted server-bound responsesHTTP 202 Accepted, empty body.
JSON-RPC idsMust be string or integer. null or any other shape → -32600 Invalid Request.
JSON-RPC batchesTop-level JSON arrays explicitly rejected with -32600 (MCP removed batching).
notifications/cancelledCancels the in-flight tool call; the originating HTTP request closes with 200 and an empty body (no JSON-RPC envelope, per spec).

The session, subscription, and pending-request ETS tables are protected; mutators run in the session manager so a non-owning process cannot tamper with the table.

Protocol Differences

vs Legacy HTTP Transport

FeatureLegacy (start_http)Streamable (start_http_stream)
Protocol Version2024-11-052025-11-25 (negotiates downward)
Claude CodeNot supportedSupported
SessionsNoYes
SSE ResponsesNoYes
GET for streamsNoYes
DELETE for cleanupNoYes
Origin validationYesYes

When to Use

  • Use start_http_stream for Claude Code integration
  • Use start_http for simple JSON-RPC clients
  • Use start_stdio for Claude Desktop integration

Embedding in another HTTP server

The built-in h1/h2 server is one binding over a transport-neutral engine, barrel_mcp_http_engine. If you already run an HTTP server (for example the Livery web framework) you can serve MCP through it by calling the engine directly, with no second listener.

For each request, read the method, path, headers and body, then call:

barrel_mcp_http_engine:handle(Method, Path, Headers, Body, Responder, Config).
  • Method is the request method binary (<<"POST">>, <<"GET">> …).
  • Path is the request target (a query string is allowed; the engine strips it).
  • Headers is a [{binary(), binary()}] proplist; lookups are case-insensitive.
  • Body is the full request body (<<>> when there is none).

Responder is a map of closures the engine uses to send the response, so it never touches a socket:

#{reply        => fun(Status, Headers, Body) -> ok end,
  stream_start => fun(Status, Headers) -> ok end,
  stream_chunk => fun(Data) -> ok | {error, term()} end,
  stream_end   => fun() -> ok end}

Headers passed back to the closures is a [{binary(), binary()}] proplist. A normal response is a single reply; a Server-Sent-Events response is stream_start, then repeated stream_chunk, then stream_end. handle/6 runs in the calling process; for a long-lived GET SSE stream it blocks until the session ends or the host signals a disconnect by sending the calling process the message mcp_disconnect.

Config is the engine configuration. Build it the way the bindings do (barrel_mcp_http_stream:start/1 for mode => stream, barrel_mcp_http:start/1 for mode => simple) using the shared helpers:

Config = #{
    mode => stream,            %% or `simple'
    auth_config =>
        barrel_mcp_http_engine:init_auth(#{provider => barrel_mcp_auth_none}),
    session_enabled => true,
    allowed_origins => any,    %% or a resolved allow-list
    allow_missing_origin => true,
    sse_buffer_size => 256,
    resource_metadata => undefined
}.

The engine handles routing (/mcp, /, and /.well-known/oauth-protected-resource), sessions, CORS, Origin validation, authentication and async tool calls, identically to the built-in server.

Example: Complete Server

-module(my_mcp_server).
-export([start/0]).

start() ->
    %% Start the application
    application:ensure_all_started(barrel_mcp),

    %% Register tools
    barrel_mcp:reg_tool(<<"greet">>, ?MODULE, greet, #{
        description => <<"Greet someone">>,
        input_schema => #{
            <<"type">> => <<"object">>,
            <<"properties">> => #{
                <<"name">> => #{<<"type">> => <<"string">>}
            }
        }
    }),

    %% Start streamable HTTP server
    {ok, _} = barrel_mcp:start_http_stream(#{
        port => 9090,
        auth => #{
            provider => barrel_mcp_auth_apikey,
            provider_opts => #{
                keys => #{<<"test-key">> => #{subject => <<"tester">>}}
            }
        }
    }),

    io:format("MCP server running on http://localhost:9090/mcp~n"),
    io:format("Add to Claude Code:~n"),
    io:format("  claude mcp add my-server --transport http http://localhost:9090/mcp --header \"X-API-Key: test-key\"~n").

greet(Args) ->
    Name = maps:get(<<"name">>, Args, <<"World">>),
    <<"Hello, ", Name/binary, "!">>.

See Also