HTTP Plug for MCP (Model Context Protocol) requests. Compatible with Phoenix and Cowboy servers.
This plug provides HTTP transport for MCP servers, allowing integration with standard Elixir web applications. It supports both regular POST requests for RPC calls and Server-Sent Events (SSE) for real-time communication.
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,
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.
Server-Sent Events (:sse_mode)
: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.
Session ids
Client-supplied mcp-session-id (and legacy x-session-id) header values
are validated before use: 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.
Sessions without a live SSE connection remain subscribed for reconnection
and are removed by ExMCP.SessionManager when they expire.
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.