Wymcp.Plugs.Auth (Wymcp v0.5.0)

View Source

Authentication plug for MCP requests.

Reads the auth module from the mount module's options (conn.assigns[:wymcp][:auth]) and calls its Wymcp.Auth.authenticate/1 callback. When no auth module is configured, defaults to Wymcp.Auth.Noop (pass-through).

On authentication failure, returns HTTP 401 with a WWW-Authenticate challenge as required by the MCP 2025-11-25 specification. By default the challenge is bare Bearer; consumers may append RFC 6750 auth-params (an RFC 9728 resource_metadata pointer, a scope hint) via the router's :www_authenticate option — see Wymcp.Router. If rendering the configured params raises (e.g. a misconfigured MFA), the challenge degrades to bare Bearer for that request and the error is logged naming the option — the 401 contract survives misconfiguration. The response body follows the route's error dialect (the :error_dialect init option): a JSON-RPC error with code -32600 (Invalid Request) on the default :json_rpc dialect — the POST pipeline — or the plain-JSON %{error: message} object under :plain_json, which Wymcp.Router's GET and DELETE routes pass when they run the wire checks its wire-check invariant names.

Observability

A 401 from either branch is a rejection: it carries the rejection mark and emits [:wymcp, :wire, :reject] like every other rejection wymcp sends, with reason :unauthenticated when the auth module returned {:error, reason} and :auth_error when it raised. The consumer's reason — a string, or an atom the wire renders as its string — rides that event as message.

The raise branch emits a second event, [:wymcp, :auth, :error] (see Wymcp.Telemetry): the rejection records that the client got a 401, the fault records that the consumer's module is broken, and the two are orthogonal. Its metadata names the module, the exception class and message, the conn's HTTP verb, the body's method and message_id, and crash_reason — the exception and its stacktrace, which a handler cannot rebuild from the strings.

The telemetry message_id is read straight from the body and deliberately differs from the id the 401's envelope carries: the wire answer applies Wymcp.Response.rejection_id/1, which is nil on every non-request message, while an operator diagnosing a rejection wants the id the client actually sent. That is why request_field/2 survives at the two observability sites and nowhere else.

Neither branch writes a Logger line of its own: Wymcp.Telemetry.Logger renders both events, and a consumer wanting different lines writes its own handler rather than living with wymcp's.