MCP's authorization model (HTTP transports only): the MCP server is an OAuth
2.1 resource server; tokens are issued by an external authorization
server discovered via RFC 9728 protected-resource metadata. noizu_mcp
implements both halves — enforcement on the server, the full flow on the
client. It never implements an authorization server.
Server side: enforcing tokens
Implement Noizu.MCP.Auth.TokenVerifier and hand it to the plug:
defmodule MyApp.MCPTokenVerifier do
@behaviour Noizu.MCP.Auth.TokenVerifier
@impl true
def verify(token, _conn_info, _opts) do
case MyApp.Auth.verify_jwt(token) do
# IMPORTANT: validate audience (RFC 8707) — the token must be *for this server*
{:ok, %{"aud" => "https://api.example.com/mcp"} = claims} ->
if "mcp" in String.split(claims["scope"] || "", " "),
do: {:ok, claims},
else: {:error, :insufficient_scope, %{scope: "mcp"}}
_ ->
{:error, :invalid_token}
end
end
end
forward "/mcp", Noizu.MCP.Transport.StreamableHTTP.Plug,
server: MyApp.MCP,
auth: [
verifier: {MyApp.MCPTokenVerifier, []},
resource_metadata: "https://api.example.com/.well-known/oauth-protected-resource"
]The plug then:
- rejects missing/invalid tokens with 401 + a
WWW-Authenticate: Bearerchallenge carryingresource_metadata(how clients bootstrap discovery) - rejects
{:error, :insufficient_scope, %{scope: ...}}with 403 and ascopehint (how clients know to step up) - on success exposes the claims to every handler as
ctx.assigns.auth_claims
One adjacent caution: hiding tools from tools/list (hidden: true, see
Toolkits, Categories & Hidden Tools) is
presentation, not authorization — hidden tools remain callable by name.
Enforce real permissions here (token scopes, ctx.assigns.auth_claims
checks inside handlers), never via listing visibility.
Serve the RFC 9728 document next to it:
forward "/.well-known/oauth-protected-resource", Noizu.MCP.Auth.ProtectedResourceMetadataPlug,
resource: "https://api.example.com/mcp",
authorization_servers: ["https://auth.example.com"],
scopes_supported: ["mcp"]Client side
Static tokens
For machine-to-machine setups where you already hold a credential:
transport: {:streamable_http,
url: "https://api.example.com/mcp",
auth: {Noizu.MCP.Auth.Static, token: System.fetch_env!("MCP_TOKEN")}}Full OAuth 2.1 flow
Noizu.MCP.Auth.OAuth runs the whole chain on the first 401:
WWW-Authenticate → RFC 9728 resource metadata (falling back to the
default well-known path on the MCP origin) → RFC 8414 / OIDC authorization
server discovery → PKCE (S256) authorization request with state and the
RFC 8707 resource indicator → code exchange → automatic refresh and
scope step-up on later 401/403s.
One thing cannot live in a library: putting the authorization URL in front
of a human. You supply that as the authorize_user callback:
transport: {:streamable_http,
url: "https://api.example.com/mcp",
auth: {Noizu.MCP.Auth.OAuth,
client_id: "my-client",
redirect_uri: "http://localhost:8914/callback",
scope: "mcp",
authorize_user: &MyApp.OAuthBrowser.run/1}}authorize_user receives the fully-built authorization URL and must return
{:ok, %{"code" => code, "state" => state}} — typically by opening the
browser and catching the redirect on a loopback listener (the
redirect_uri above). Return {:error, reason} to abort.
Validate this seam early
authorize_user is the API most likely to evolve before 1.0. If you wire
it into a real product, please report friction.
Custom strategies
Anything token-shaped can implement Noizu.MCP.Auth.ClientStrategy:
init/1 (receives your opts plus :mcp_url), headers/1 (returns headers
- updated state), and
handle_unauthorized/3(parse the challenge, refresh or re-acquire, return{:retry, state}or{:error, reason, state}). The transport retries a request at most twice after{:retry, _}.