# `ExMCP.HttpPlug`
[🔗](https://github.com/azmaveth/ex_mcp/blob/v1.0.0/lib/ex_mcp/http_plug.ex#L1)

HTTP Plug for MCP (Model Context Protocol) requests.
Compatible with Phoenix and Cowboy servers.

This plug provides Streamable HTTP transport for MCP servers, allowing
integration with standard Elixir web applications. Modern SSE responses are
owned by the POST request that opened them and require no transport flag.

The deprecated MCP 2024-11-05 HTTP+SSE transport remains available throughout
ExMCP 1.x by explicitly setting `legacy_http_sse: true`. The rc.5
`sse_enabled: true` option remains an alias for compatibility. New servers do
not enable this deprecated transport by default.

## Handler options

`:handler_opts` configures the argument passed to a handler module's
`init/1`. It may be a static term, a one-arity function called with the
`Plug.Conn`, a two-arity function called with the `Plug.Conn` and decoded
JSON-RPC request, or an `{module, function, extra_args}` tuple. MFA handlers
are called as `apply(module, function, [conn, request | extra_args])`.

`:handler_call_timeout` is the server-side deadline, in milliseconds, for
each call from the plug into a Handler process (default: `10_000`). It is
independent of client request and stream timeouts.

## Usage

    # With Cowboy
    {:ok, _} = Plug.Cowboy.http(ExMCP.HttpPlug, [
      handler: MyApp.MCPServer,
      server_info: %{name: "my-app", version: "1.0.0"}
    ], port: 4000)

    # With Phoenix
    plug ExMCP.HttpPlug,
      handler: MyApp.MCPServer,
      server_info: %{name: "my-app", version: "1.0.0"}

## OAuth 2.1 Integration

To enable OAuth 2.1 bearer token validation:

    plug ExMCP.HttpPlug,
      handler: MyApp.MCPServer,
      server_info: %{name: "my-app"},
      oauth_enabled: true,
      resource: "https://mcp.example.com/mcp",
      authorization_servers: ["https://auth.example.com"],
      auth_config: %{
        introspection_endpoint: "https://auth.example.com/introspect",
        realm: "my-mcp-server" # Optional, defaults to server_info.name
      }

## Security

### Origin validation (`:validate_origin`, `:allowed_origins`)

With `validate_origin: true` (the default), any request carrying an
`Origin` header is rejected with `403` unless the origin is listed in
`:allowed_origins` (or `:allowed_origins` is `:any`). There is no
"same origin as the Host header" fallback: in a DNS rebinding attack the
Host header is attacker-controlled, so such a comparison would always pass.

Requests **without** an `Origin` header are allowed. Non-browser clients
(CLIs, SDKs, server-to-server callers) do not send the header; use
`:allowed_hosts` to protect them against DNS rebinding.

### Host validation (`:allowed_hosts`)

`:allowed_hosts` is either `:any` (default, no restriction) or a list of
hostnames. When a list is given, requests whose `Host` header does not
match an entry are rejected with `421` before any processing. Ports are
ignored and IPv6 hosts match with or without brackets, so
`allowed_hosts: ["localhost", "127.0.0.1", "[::1]", "::1"]` accepts
`localhost:4000` and `[::1]:8080`. Servers started via
`ExMCP.Server.Transport` with a localhost bind get this allow-list by
default.

### Deprecated HTTP+SSE (`:legacy_http_sse`, `:sse_mode`)

`:legacy_http_sse` explicitly enables the standalone GET transport used by
legacy MCP revisions. It defaults to `false`. `:sse_enabled` is a retained
1.x alias and is planned for removal in ExMCP 2.0.

`:sse_mode` is `:stream` (default) or `:oneshot`. `:stream` starts an
`ExMCP.HttpPlug.SSEHandler` and holds the request open for the lifetime of
the stream; `:oneshot` writes a single `connected` event and returns, which
suits test harnesses and health checks.

MCP 2026-07-28 does not use that GET stream. A modern
`subscriptions/listen` POST owns its SSE response directly. The response
process closes its registry entry when the client disconnects and emits SSE
comment keepalives every `:subscription_keepalive_interval_ms` milliseconds
(default: `15_000`; set `:infinity` to disable).

An ordinary modern request that opts into progress or request logs also owns
its POST response stream. `notifications/progress` and
`notifications/message` are written only there, followed by one final
JSON-RPC response that closes the stream. A disconnect or chunk failure
cancels that request's worker and temporary handler without affecting other
requests or subscriptions.

### Session ids

Session IDs are issued by the server. Client-supplied `mcp-session-id` (and
legacy `x-session-id`) header values are validated and must identify an
existing session bound to the same authorization identity. Values are at
most 128 bytes from the character set
`A-Z a-z 0-9 . _ ~ + / = -` (covering UUIDs and base64/base64url tokens).
Invalid values are rejected with a `400` JSON-RPC error and are never
echoed back.

# `broadcast_resource_update`

```elixir
@spec broadcast_resource_update(String.t()) :: %{
  subscribers: non_neg_integer(),
  delivered: non_neg_integer()
}
```

Broadcasts a resource update to each live SSE client subscribed to `uri`.

Subscription lookup is performed directly against ETS, and delivery uses
independent tasks so backpressure from one client does not block the rest.
The event is persisted before live delivery; sessions without a live SSE
connection remain subscribed and receive it through Last-Event-ID replay
after reconnecting. Expired sessions are removed by `ExMCP.SessionManager`.

# `call`

Processes HTTP connections for MCP protocol.

Host validation (`:allowed_hosts`) runs before any routing so that DNS
rebinding attempts are rejected before request processing.

# `init`

Initializes the plug with configuration options.

# `start_link`

> This function is deprecated. The session table is owned by ExMCP.HttpPlug.SessionRegistry, started with the :ex_mcp application.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
