Main Plug implementation for MCP HTTP transport with SSE support.
This module provides a complete MCP server implementation with:
- JSON-RPC 2.0 message handling
- Server-Sent Events (SSE) streaming (legacy protocols)
- Stateless transactional request/response (MCP
2026-07-28) - CORS handling and security features
- Session management integration
- Origin validation
Legacy clients (≤ 2025-11-25) carry an mcp-session-id header and may
hold a persistent GET SSE stream for server-initiated traffic. Stateless
core clients (2026-07-28) send no session header — every POST is
self-contained and any node behind a round-robin load balancer can serve
the call. Both modes run from the same router; the protocol version on
each request determines the dispatch path.
Phoenix
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# ...
pipeline :mcp do
plug :accepts, ["json", "sse"]
plug Plug.Parsers,
parsers: [{:json, length: 1_000_000}],
pass: ["application/json"],
json_decoder: JSON
end
scope "/mcp" do
pipe_through :mcp
forward "/", Phantom.Plug,
router: MyApp.MCPRouter,
pubsub: MyApp.PubSub
end
endPlug.Router
defmodule MyAppWeb.Router do
use Plug.Router
plug :match
plug Plug.Parsers,
parsers: [{:json, length: 1_000_000}],
pass: ["application/json"],
json_decoder: JSON
plug :dispatch
forward "/mcp",
to: Phantom.Plug,
init_opts: [
router: MyApp.MCP.Router,
pubsub: MyApp.PubSub
]
endHere are the defaults:
[
pubsub: nil,
origins: ["http://localhost:4000"],
validate_origin: true,
validate_session: false,
hosts: :all,
session_timeout: 30000,
max_request_size: 1048576
]Local testing
Most MCP clients support Streamable HTTP natively. Configure them to connect directly to your Phantom server:
{
"mcpServers": {
"my_app": {
"type": "http",
"url": "http://localhost:4000/mcp"
}
}
}Avoid mcp-remote and mcp-proxy
Third-party proxies like mcp-remote and mcp-proxy can break MCP
features such as elicitation, resource subscriptions, and session
management. Connect directly over HTTP when possible.
If your client only supports stdio, use Phantom.Stdio instead of
proxying HTTP through a stdio wrapper.
For a direct stdio transport without HTTP, see Phantom.Stdio.
Telemetry
Telemetry is provided with these events:
[:phantom, :plug, :request, :connect]with meta:~w[session router conn opts]a[:phantom, :plug, :request, :disconnect]with meta:~w[session router conn]a[:phantom, :plug, :request, :terminate]with meta:~w[session router conn]a[:phantom, :plug, :request, :exception]with meta:~w[session router conn stacktrace request exception]a
Summary
Functions
Callback implementation for Plug.call/2.
Initializes the plug with the given options.
Construct a WWW-Authenticate header as defined by RFC 9728 from the map.
Types
@type opts() :: [ router: module(), origins: [String.t()] | :all | mfa(), validate_origin: boolean(), validate_session: boolean(), hosts: [String.t()] | :all | mfa(), session_timeout: pos_integer(), max_request_size: pos_integer() ]
Functions
Callback implementation for Plug.call/2.
Initializes the plug with the given options.
Options
:router- The MCP router module (required):origins- List of allowed origins or:all(default: localhost):validate_origin- Whether to validate the Origin header (default: true). Requests without one are allowed: only browsers send it, and only browsers can be used for DNS rebinding.:validate_session- Whether to answer404 Not Foundto a request whosemcp-session-idthis server did not issue in aninitializeresponse, or that was terminated withDELETE, so the client initializes again (default: false). Phantom keeps that record in memory, so a restart forgets every session. Apps that persist sessions across restarts should leave this off and answer{:not_found, message}fromPhantom.Router.connect/2instead.:hosts- List of allowed hosts (from theHostheader, without the port),:all, or an MFA called with the host prepended to its arguments (default::all). A server bound to localhost should sethosts: ["localhost", "127.0.0.1", "[::1]"]to reject DNS rebinding requests that a browser sends with an attacker'sHost.:session_timeout- Session timeout in milliseconds (default: 30s):max_request_size- Maximum request size in bytes (default: 1MB)
Construct a WWW-Authenticate header as defined by RFC 9728 from the map.
This requires the map to contain a :method to indicate acceptable authentication
methods, typically "Bearer", and then rest of the attributes will be serialized
into the header as key=value.
For example,
iex> Phantom.Plug.www_authenticate(%{
...> method: "Bearer",
...> resource_metadata: "https://myapp.com/.well-known/oauth-protected-resource",
...> max_age: 42000
...> })
~s|Bearer max_age="42000", resource_metadata="https://myapp.com/.well-known/oauth-protected-resource"|https://datatracker.ietf.org/doc/html/rfc9728#name-use-of-www-authenticate-for