Phoenix Plug for MCP server integration.
Supports multiple transports:
:streamable_http(default) — MCP Streamable HTTP transport:sse— Legacy HTTP+SSE transport (MCP 2024-11-05, deprecated):websocket— WebSocket transport viaPhoenix.Socket.Transport
Prerequisites
Before using this plug, you must start the Anubis MCP server in your application. The transport backend must match the transport used by the plug.
Streamable HTTP (default)
# In your application.ex
children = [
{MyApp.MCP, transport: {:streamable_http, start: true}},
MyAppWeb.Endpoint
]SSE (legacy)
children = [
{MyApp.MCP, transport: {:sse, start: true}},
MyAppWeb.Endpoint
]Multiple transports
One transport is supported per server module — anubis_mcp registers
process names derived from the server module, so starting two transports
for the same server collides. Use a dedicated server module per transport:
children = [
{MyApp.MCP, transport: {:streamable_http, start: true}},
MyAppWeb.Endpoint
]See Ectomancer.child_spec/2 for a helper that generates the supervision
entry for a single transport.
Router Integration
Streamable HTTP (default)
scope "/mcp" do
pipe_through :api
forward "/", Ectomancer.Plug, server: MyApp.MCP
endSSE (legacy)
scope "/mcp" do
get "/sse", Ectomancer.Plug, server: MyApp.MCP, transport: :sse
post "/sse", Ectomancer.Plug, server: MyApp.MCP, transport: :sse
endWebSocket
WebSocket requires a Phoenix.Socket.Transport in your endpoint, not a Plug route.
Use the socket macro instead of forward:
socket "/mcp/ws", Ectomancer.Plug.WebSocket,
server: MyApp.MCP,
websocket: [connect_info: [:x_headers, :uri, :peer_data]]Transport Options
| Transport | Option Value | Route Method | Backend |
|---|---|---|---|
| Streamable HTTP | :streamable_http (default) | forward | Anubis.Server.Transport.StreamableHTTP.Plug |
| SSE (legacy) | :sse | get + post | Anubis.Server.Transport.SSE.Plug (deprecated) |
| WebSocket | :websocket | socket (endpoint) | Ectomancer.Plug.WebSocket |
Actor Extraction
The actor is extracted using the configured actor_from function:
config :ectomancer,
actor_from: fn conn ->
conn
|> Plug.Conn.get_req_header("authorization")
|> List.first()
|> case do
nil -> {:error, :unauthorized}
"Bearer " <> token -> MyApp.Auth.verify_token(token)
_ -> {:error, :unauthorized}
end
endIf no actor_from is configured, the actor defaults to nil.
WebSocket Actor Extraction
For WebSocket connections, actor_from receives a map (not a Plug.Conn):
config :ectomancer,
actor_from: fn
%Plug.Conn{} = conn ->
# HTTP actor extraction
Ectomancer.Plug.extract_bearer_token(conn) |> verify_token()
info when is_map(info) ->
# WebSocket: extract from query params or x_headers
case info.params["token"] do
nil -> {:error, :unauthorized}
token -> verify_token(token)
end
endOptions
:server- The MCP server module (required):transport- Transport type::streamable_http,:sse, or:websocket(default::streamable_http):session_header- Custom header name for session ID (default: "mcp-session-id", streamable_http only):request_timeout- Request timeout in milliseconds (default: 30000)
The actor will be available in tool handlers via frame.assigns[:ectomancer_actor].
Summary
Functions
Handles the MCP request by extracting the actor from the connection and
delegating to the appropriate transport plug based on the :transport option.
Extracts the actor from the connection using the configured actor_from function.
Helper function to extract API key from a custom header.
Helper function to extract a Bearer token from the Authorization header.
Gets the current actor from the connection assigns.
Functions
Handles the MCP request by extracting the actor from the connection and
delegating to the appropriate transport plug based on the :transport option.
Calls extract_actor/1 to resolve the actor, then either rejects with
401 (if {:error, _} returned) or stores the actor in
conn.assigns[:ectomancer_actor] and forwards to the transport plug.
@spec extract_actor(Plug.Conn.t()) :: any()
Extracts the actor from the connection using the configured actor_from function.
Reads the application config for :ectomancer, :actor_from. If set, calls the
function with the conn and returns its result. If unset, returns nil.
The actor_from function can return:
- Any value — the actor (e.g., a
%User{}struct, a string, an atom) {:error, reason}— the request will be rejected with HTTP 401
Configuration
config :ectomancer,
actor_from: fn conn ->
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> token] -> MyApp.Auth.verify_token(token)
_ -> {:error, :unauthorized}
end
endExamples
# With JWT token verification
config :ectomancer,
actor_from: fn conn ->
with ["Bearer " <> token] <- Plug.Conn.get_req_header(conn, "authorization"),
{:ok, claims} <- MyApp.JWT.verify(token) do
MyApp.Accounts.get_user!(claims["sub"])
else
_ -> {:error, :unauthorized}
end
end
# With session cookie (read from conn before Plug session)
config :ectomancer,
actor_from: fn conn ->
case Plug.Conn.get_req_header(conn, "cookie") do
[cookie] -> MyApp.Auth.verify_session(cookie)
_ -> {:error, :unauthorized}
end
end
# Public API (no auth required)
# Just omit actor_from — returns nil, tools without authorization pass throughThe extracted actor is stored in conn.assigns[:ectomancer_actor] and
propagated to tool handlers via frame.assigns[:ectomancer_actor].
@spec extract_api_key(Plug.Conn.t(), String.t()) :: String.t() | nil
Helper function to extract API key from a custom header.
Examples
api_key = Ectomancer.Plug.extract_api_key(conn, "x-api-key")
@spec extract_bearer_token(Plug.Conn.t()) :: String.t() | nil
Helper function to extract a Bearer token from the Authorization header.
Examples
token = Ectomancer.Plug.extract_bearer_token(conn)
# Returns: "abc123" or nil
@spec get_actor(Plug.Conn.t()) :: any()
Gets the current actor from the connection assigns.
Examples
actor = Ectomancer.Plug.get_actor(conn)