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
@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.
Processes HTTP connections for MCP protocol.
Host validation (:allowed_hosts) runs before any routing so that DNS
rebinding attempts are rejected before request processing.
Initializes the plug with configuration options.