Wymcp.Plugs.Auth (Wymcp v0.1.1)

View Source

Authentication plug for MCP requests.

Reads the auth module from router opts (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 the router's GET/DELETE wire-check call sites pass.

Observability

The plug emits two telemetry events (see Wymcp.Telemetry) alongside the wire response:

  • [:wymcp, :auth, :reject] — the auth module returned {:error, reason}. Metadata includes auth_module, reason, request_id, and method.
  • [:wymcp, :auth, :error] — the auth module raised. Metadata includes auth_module, exception, error, request_id, and method.

Both events carry http_method — the conn's HTTP verb ("POST", "GET", "DELETE"). request_id and method come from the parsed request body, so both are nil off POST. Both branches also emit a structured Logger line with the same metadata so operators without a telemetry handler still get attribution.

The telemetry request_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 four observability sites and nowhere else.