HTTP plug implementing the MCP Streamable HTTP transport for the 2026-07-28
protocol.
This is the entry point of an MCP server over HTTP. It is a Plug.Router, so
you mount it in a Plug or Phoenix router and it answers Model Context Protocol
requests at that path. A client POSTs a JSON-RPC message, and the transport
replies with either a single JSON response or a Server-Sent Events stream,
depending on what the server returns.
The transport is stateless: each request is validated, run by a fresh server
process, and answered on its own. The server that handles the decoded request
is GenMCP.Suite by default,
so the smallest useful mount needs no :server option and just lists what to
serve:
forward "/mcp", GenMCP.Transport.StreamableHTTP,
server_name: "My App",
server_version: "1.0.0",
tools: [MyApp.AddTool]:server_name and :server_version are what the Suite reports to clients
and are required; every example below carries them for that reason.
To run a custom GenMCP implementation instead of the Suite, pass it as
:server:
forward "/mcp", GenMCP.Transport.StreamableHTTP, server: MyApp.ServerOptions
Options are read at three layers. The transport keeps the HTTP connection options for itself and passes everything else to the server, which takes its own wrapper options and forwards the rest to the server implementation.
HTTP connection options
:assigns(map/0) - A map of assigns to define to the channel passed to tools, resources, etc. The default value is%{}.:copy_assigns(list ofatom/0) - A list of assigns keys that will be copied from the conn to the channel. Those will overwrite the assigns from the:assignsoption above. The default value is[].:allowed_origins- Origin allowlist for DNS-rebinding protection. A request carrying anOriginheader not in the list is rejected with 403 Forbidden. Requests without anOriginheader (non-browser clients) are always accepted. Use:anyto disable the check (e.g. behind a gateway that already validates origins). The default value is[].
MCP server options
:server selects the GenMCP implementation that handles the decoded request.
See GenMCP for the {module, arg} form.
:server- TheGenMCPbehaviour server implemetation that will handle MCP messages. If a simple atom, it will receive all other options given to the session. The default value isGenMCP.Suite.
Server implementation options
Every option not listed above is forwarded to the server implementation. The
default server, GenMCP.Suite, accepts:
:server_name(String.t/0) - Required.:server_version(String.t/0) - Required.:server_title- The default value isnil.:tools- The list ofGenMCP.Suite.Toolimplementations that will be available in the server. List items can be either module names,{module, arg}tuples or descriptor maps. The default value is[].:resources- The list ofGenMCP.Suite.ResourceRepoimplementations to serve resources from. List items can be either module names,{module, arg}tuples or descriptor maps. The default value is[].:prompts- A list ofGenMCP.Suite.PromptRepoimplementations to generate prompts with. List items can be either module names,{module, arg}tuples or descriptor maps. The default value is[].:extensions- A listExtensionimplementations to add more tools, resource repositories and prompt repositories. List items can be either module names,{module, arg}tuples or descriptor maps. The default value is[].:subscription_handler- A handler for subscription requests. Either a module name, a{module, arg}tuple or a descriptor map. The default value isnil.:send_server_info(boolean/0) - Includes the server'sio.modelcontextprotocol/serverInfometadata (built from:server_name,:server_version, and:server_title) in the_metaof every result, as the spec recommends. The default value istrue.
Passing request data to handlers
Handlers read per-request context from the GenMCP.Mux.Channel they are given.
Use :assigns and :copy_assigns to put your own data there. :assigns holds
static values set on every channel. :copy_assigns lists conn assign keys to
copy from the connection onto the channel, which is how an upstream
authentication plug hands the authenticated identity to your tools: the plug
puts :current_user on the conn, and you copy it across.
pipeline :mcp_auth do
plug MyAppWeb.AuthPlug
end
scope "/mcp" do
pipe_through :mcp_auth
forward "/", GenMCP.Transport.StreamableHTTP,
server_name: "My App",
server_version: "1.0.0",
tools: [MyApp.AddTool],
copy_assigns: [:current_user]
endA copied conn assign overwrites a static :assigns entry of the same key.
DNS-rebinding protection
A browser-based client sends an Origin header. The transport rejects a
request whose Origin is not in :allowed_origins with 403 Forbidden, which
stops a malicious page from rebinding DNS to reach a local MCP server. A request
with no Origin header (a non-browser client) is always accepted. Set
allowed_origins: :any to disable the check, for example behind a gateway that
already validates the origin.
forward "/mcp", GenMCP.Transport.StreamableHTTP,
server_name: "My App",
server_version: "1.0.0",
tools: [MyApp.AddTool],
allowed_origins: ["https://app.example.com"]Multiple endpoints in one router
On Phoenix 1.8 and later you can mount the transport directly as many times as
you need. Each forward keeps its own options, so different endpoints can serve
different tools, resources, or origins:
scope "/mcp" do
forward "/files", GenMCP.Transport.StreamableHTTP,
server_name: "My App (files)",
server_version: "1.0.0",
tools: [MyApp.FileTool]
forward "/admin", GenMCP.Transport.StreamableHTTP,
server_name: "My App (admin)",
server_version: "1.0.0",
tools: [MyApp.AdminTool]
endPhoenix resolves a forwarded plug to a path by module, so reverse route lookup
(path helpers and ~p verified routes) for a module mounted at several paths
returns only the first one. This affects URL generation, not request dispatch.
If you generate URLs to these endpoints, give each its own module with
defplug/1 so each resolves to its own path.
Phoenix before 1.8
Older Phoenix routers refuse to forward the same module more than once and raise at compile time:
** (ArgumentError) GenMCP.Transport.StreamableHTTP has already been
forwarded to. A module can only be forwarded a single time.Give each endpoint its own module with defplug/1, then forward to those
modules instead of to the transport:
require GenMCP.Transport.StreamableHTTP, as: StreamableHTTP
StreamableHTTP.defplug(MyAppWeb.FilesMcp)
StreamableHTTP.defplug(MyAppWeb.AdminMcp)
scope "/mcp" do
forward "/files", MyAppWeb.FilesMcp,
server_name: "My App (files)",
server_version: "1.0.0",
tools: [MyApp.FileTools]
forward "/admin", MyAppWeb.AdminMcp,
server_name: "My App (admin)",
server_version: "1.0.0",
tools: [MyApp.AdminTools]
end
Summary
Functions
Callback implementation for Plug.call/2.
Defines a named plug module that delegates to GenMCP.Transport.StreamableHTTP.
Initializes the plug, returning the prepared transport configuration.
Functions
Callback implementation for Plug.call/2.
Defines a named plug module that delegates to GenMCP.Transport.StreamableHTTP.
Use this to mount more than one MCP endpoint on a router that allows a module to
be forwarded only once. Phoenix before 1.8 is the common case: forwarding
GenMCP.Transport.StreamableHTTP at two paths raises ArgumentError with the
message "has already been forwarded to. A module can only be forwarded a single
time". The generated module is a distinct plug that delegates both init/1 and
call/2 to the transport, so you forward to it exactly like the transport
itself and each endpoint is its own module. On Phoenix 1.8 and later you can
forward to GenMCP.Transport.StreamableHTTP directly instead, even for several
endpoints.
This is a macro, so require (or alias and require) the transport before
calling it, and give a literal module name:
require GenMCP.Transport.StreamableHTTP, as: StreamableHTTP
StreamableHTTP.defplug(MyAppWeb.McpPlug)
# then, in the router, mount the generated module
forward "/mcp", MyAppWeb.McpPlug,
server_name: "My App",
server_version: "1.0.0",
tools: [MyApp.AddTool]
Initializes the plug, returning the prepared transport configuration.
This is the Plug.init/1 callback. It validates the transport's own options
(:allowed_origins, :assigns, :copy_assigns) and keeps every other option
aside as the server options handed to the server on each request. The returned
value is the opaque configuration later passed to call/2; you do not build or
read it yourself.