MCP.Transport.StreamableHTTP.Plug (MCP Elixir SDK v1.1.0)

Copy Markdown View Source

Plug endpoint for the MCP Streamable HTTP transport.

Handles POST, GET, and DELETE HTTP methods at the MCP endpoint:

  • POST — receive JSON-RPC messages from clients, route to the appropriate MCP.Server session, and return the response
  • GET — open an SSE stream for server-initiated messages
  • DELETE — terminate a session

Usage

Mount this Plug in your HTTP server (e.g., with Bandit):

# Create a Plug with a server factory function
plug = MCP.Transport.StreamableHTTP.Plug.new(
  server_mod: MyApp.McpHandler,
  server_opts: []
)

# Start Bandit with the Plug
{:ok, _} = Bandit.start_link(plug: plug, port: 8080)

The public Plug option is :server_mod. Do not use :handler here — :handler is the internal MCP.Server.start_link/1 key that this Plug constructs for you; passing it to the Plug has no effect.

Client handshake (initialize → initialized → tools/call)

Each session's MCP.Server starts in the :waiting state and only becomes :ready after it receives the notifications/initialized notification. A client MUST drive the full MCP handshake, in order:

  1. POST an initialize request — the response carries the MCP-Session-Id header identifying the new session.
  2. POST a notifications/initialized notification on that session (include the MCP-Session-Id header). The server returns 202 Accepted and transitions to :ready.
  3. Only then POST tools/call (and other requests) on that session.

Going straight from initialize to tools/call — skipping step 2 — is the single most common integration mistake: the server rejects the request with "Server not initialized", which can surface as a hang or a confusing error. MCP.Client.connect/1 performs this handshake for you; if you drive the transport with a raw HTTP client, you must send notifications/initialized yourself.

Options

  • :server_mod (required) — the MCP.Server.Handler module. This is the public Plug option (not :handler, which is internal to MCP.Server).
  • :server_opts — options to pass to MCP.Server.start_link/1 (only :server_info, :capabilities, and :instructions are forwarded)
  • :handler_opts — options passed to the handler's MCP.Server.Handler.init/1 for each session. Either a static keyword list, or a factory function (Plug.Conn.t() -> keyword()) evaluated once per session at initialize (default: []). See "Request-scoped handler options" below.
  • :session_id_generator — function that generates session IDs (default: UUID.uuid4/0). Pass nil for stateless mode.
  • :enable_json_response — if true, return application/json instead of SSE for simple request/response (default: false)
  • :protocol_version — expected protocol version (default: "2025-11-25")

Request-scoped handler options

:handler_opts threads options into the per-session handler's MCP.Server.Handler.init/1. This is the supported seam for carrying a request-established identity — validated by an upstream auth Plug and placed in conn.assigns — into handler state, without forking this Plug.

Two forms:

  • Statichandler_opts: [region: "eu"]. Passed verbatim to init/1.
  • Factoryhandler_opts: fn conn -> [identity: conn.assigns.identity] end. Evaluated once per session, at the initialize POST, against that request's conn. The returned keyword list is bound into handler state for the session's whole life; later requests on the same session reuse it and do not re-run the factory.

Example:

# Your auth Plug runs first and sets conn.assigns.identity
plug = MCP.Transport.StreamableHTTP.Plug.new(
  server_mod: MyApp.McpHandler,
  handler_opts: fn conn -> [identity: conn.assigns.identity] end
)

defmodule MyApp.McpHandler do
  @behaviour MCP.Server.Handler
  @impl true
  def init(opts), do: {:ok, %{identity: Keyword.fetch!(opts, :identity)}}

  @impl true
  def handle_call_tool("whoami", _args, state),
    # acts as the bound principal — NEVER an identity taken from tool args
    do: {:ok, [%{"type" => "text", "text" => state.identity.subject}], state}
end

Security: identity must be established server-side by the authenticated Plug pipeline and bound at the initialize trust boundary — never supplied by the model via tool-call arguments, which are model-controlled and spoofable. The handler stays transport-agnostic: identity arrives through init/1 opts, and the conn is never leaked into handle_call_tool/3,4.

The factory form requires a conn, so it is supported only on the Plug's initialize request path. Conn-less start paths (a directly supervised MCP.Server, stdio, MCP.Transport.StreamableHTTP.PreStarted) support the static keyword form only.

A factory that raises or returns a non-keyword produces a clean JSON-RPC "Internal error" (HTTP 500, code -32603) at initialize with no session started; the detail is logged server-side and never returned to the client.

Summary

Types

Options threaded into the handler's MCP.Server.Handler.init/1.

Functions

Creates a new Plug configuration.

Types

handler_opts()

@type handler_opts() :: keyword() | (Plug.Conn.t() -> keyword())

Options threaded into the handler's MCP.Server.Handler.init/1.

Either a static keyword list, or a factory (Plug.Conn.t() -> keyword()) evaluated once per session at initialize against that request's conn.

Functions

new(opts)

Creates a new Plug configuration.

Returns a tuple {MCP.Transport.StreamableHTTP.Plug, opts} suitable for passing to Bandit or other HTTP servers.