Ectomancer.Plug (Ectomancer v1.7.0)

Copy Markdown View Source

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 via Phoenix.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
end

SSE (legacy)

scope "/mcp" do
  get  "/sse", Ectomancer.Plug, server: MyApp.MCP, transport: :sse
  post "/sse", Ectomancer.Plug, server: MyApp.MCP, transport: :sse
end

WebSocket

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

TransportOption ValueRoute MethodBackend
Streamable HTTP:streamable_http (default)forwardAnubis.Server.Transport.StreamableHTTP.Plug
SSE (legacy):sseget + postAnubis.Server.Transport.SSE.Plug (deprecated)
WebSocket:websocketsocket (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
  end

If 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
  end

Options

  • :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

call(conn, state)

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.

extract_actor(conn)

@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
  end

Examples

# 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 through

The extracted actor is stored in conn.assigns[:ectomancer_actor] and propagated to tool handlers via frame.assigns[:ectomancer_actor].

extract_api_key(conn, header_name \\ "x-api-key")

@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")

extract_bearer_token(conn)

@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

get_actor(conn)

@spec get_actor(Plug.Conn.t()) :: any()

Gets the current actor from the connection assigns.

Examples

actor = Ectomancer.Plug.get_actor(conn)