ExMCP.HttpPlug (ex_mcp v1.0.0-rc.8)

Copy Markdown View Source

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.

Summary

Functions

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

Processes HTTP connections for MCP protocol.

Initializes the plug with configuration options.

Functions

broadcast_resource_update(uri)

@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(conn, opts)

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(opts)

Initializes the plug with configuration options.

start_link(opts \\ [])

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