Wymcp. Auth behaviour
(Wymcp v0.4.0)
View Source
The consumer contract for MCP request authentication: authenticate/1
validates the request's Bearer token and adds identity to conn.assigns.
Consuming applications implement this behaviour to validate Bearer tokens
from the Authorization header. The implementation typically:
- Extracts the Bearer token from the Authorization header
- Validates it (e.g., looks up a hashed token in the database)
- On success: adds identity information to
conn.assignsand returns{:ok, conn} - On failure: returns
{:error, message}
The auth module is declared in the mount module:
defmodule MyApp.Mcp do
use Wymcp.Router,
tools: [MyApp.Tools.Events],
auth: MyApp.McpAuth
endWhen no :auth option is provided, Wymcp.Auth.Noop is used (no authentication).
authenticate/1 runs once per request on every non-fallthrough route —
POST, GET (the SSE stream), and DELETE. Authenticate from connection
data such as the Authorization header, never from the request body:
on GET and DELETE no body is parsed, so conn.body_params is
unfetched. Auth is per-request only — an already-open SSE stream lives
until its session ends, and a reconnect is a fresh GET that
re-authenticates naturally.
Token validity is not session ownership (legacy-only)
authenticate/1 proves the caller holds a valid token; nothing
binds a legacy-era session to the identity that created it. Any
authenticated principal presenting another principal's
Mcp-Session-Id can attach to (GET), drive (POST), or terminate
(DELETE) that session. The session id is the only ownership
credential — an unguessable 256-bit random value (Wymcp.Session) —
so treat session ids as secrets: they appear in telemetry metadata,
which makes trusted telemetry and log sinks part of the boundary.
Single-principal deployments are unaffected, and the modern era has
no sessions at all.
Example implementation
defmodule MyApp.McpAuth do
@behaviour Wymcp.Auth
@impl Wymcp.Auth
def authenticate(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do
[header] ->
with "Bearer " <> token <- header,
{:ok, user} <- MyApp.Accounts.fetch_user_by_api_token(token) do
{:ok, Plug.Conn.assign(conn, :current_user, user)}
else
_ -> {:error, "Invalid Bearer token"}
end
[] ->
{:error, "Missing Authorization header"}
[_, _ | _] ->
{:error, "Duplicated Authorization header. Send exactly one Authorization header."}
end
end
endThe three-way read is the point. Plug.Conn.get_req_header/2 returns
every value of a repeated header, so a two-way ["Bearer " <> token]
match folds a duplicated Authorization into the same answer as a missing
one — a wrong message, not merely a vague one, and it is the arm a
copied-and-trimmed example loses first.
Authorization is the one singleton header (docs/glossary.md) wymcp
cannot validate centrally: Wymcp.Plugs.SingletonHeaders runs after this
callback, because a 401 must win over any answer that depends on reading
the request further. A duplicated Authorization therefore reaches
authenticate/1 untouched, and this example is the only leverage wymcp
has on it.
MCP specification notes
Per the MCP 2025-11-25 spec, servers that require authentication MUST return
401 with a WWW-Authenticate: Bearer challenge when the token is missing or
invalid. The Wymcp.Plugs.Auth plug handles this response format automatically
when authenticate/1 returns {:error, _}.
Summary
Callbacks
Validates the MCP request's authentication credentials.
Callbacks
@callback authenticate(conn :: Plug.Conn.t()) :: {:ok, Plug.Conn.t()} | {:error, String.t()}
Validates the MCP request's authentication credentials.
Returns {:ok, conn} with any identity information added to assigns,
or {:error, message} if authentication fails.