Phantom.Plug (phantom_mcp v0.7.3)

Copy Markdown View Source

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
end

Plug.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
      ]
end

Here 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

opts()

@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()
]

www_authenticate()

@type www_authenticate() :: %{
  :method => String.t(),
  optional(String.t() | atom()) => atom() | String.t()
}

Functions

call(conn, opts)

Callback implementation for Plug.call/2.

init(opts)

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 answer 404 Not Found to a request whose mcp-session-id this server did not issue in an initialize response, or that was terminated with DELETE, 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} from Phantom.Router.connect/2 instead.
  • :hosts - List of allowed hosts (from the Host header, without the port), :all, or an MFA called with the host prepended to its arguments (default: :all). A server bound to localhost should set hosts: ["localhost", "127.0.0.1", "[::1]"] to reject DNS rebinding requests that a browser sends with an attacker's Host.
  • :session_timeout - Session timeout in milliseconds (default: 30s)
  • :max_request_size - Maximum request size in bytes (default: 1MB)

www_authenticate(info)

@spec www_authenticate(map()) :: String.t()

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