Authentication (OAuth 2.1)

Copy Markdown View Source

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: Bearer challenge carrying resource_metadata (how clients bootstrap discovery)
  • rejects {:error, :insufficient_scope, %{scope: ...}} with 403 and a scope hint (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, _}.