The previous post covered the simplest MCP tool calling interaction — no auth, open access. This one picks up where that left off: what happens when a tool is protected and the user needs to prove who they are before it will run.
Since around 2024, OAuth 2.1 integration has been steadily refined across MCP revisions. One thing that wasn't obvious to me at first: authentication isn't meant to be implemented by the MCP server itself (the resource server, in OAuth terms). It's delegated to a separate Authorization Server (AS) — which means the MCP client can hand off to a browser, let the user log in there, and simply wait for the token to come back.
Protocol versions and RFCs relevant to this flow:
- MCP spec version 2026-07-28 - Use MCP spec used in this post
- RFC 9700 — OAuth 2.1 (the consolidating spec MCP references; supersedes RFC 6749 for most purposes)
- RFC 9728 — OAuth 2.0 Protected Resource Metadata (defines the
.well-known/oauth-protected-resourceendpoint, Phase 3)- RFC 7636 — Proof Key for Code Exchange (PKCE, Phase 4)
- RFC 8252 — OAuth 2.0 for Native Apps (loopback redirect URI pattern)
- RFC 6749 §1.1 — OAuth 2.0 core; defines the roles: Resource Owner, Resource Server, Authorization Server, and Client
Example: an airplane ticket MCP server
Say we have an MCP server exposing at least two tools:
flight_search— public. Anyone can look up flights, no account needed.flight_reservation— must be bound to an account, because it touches personal data, loyalty points, and payment information.
Two designs, and which one this server uses
In practice servers land on one of two designs:
- Public discovery, gated calls —
tools/listworks for anyone (so an LLM/agent can at least see that aflight_reservationtool exists and describe it to the user), buttools/callon the sensitive tool returns 401 until authenticated. This is friendlier for capability discovery and is what most public-facing MCP servers with a mix of public/private tools do. - Gated discovery too — the whole server sits behind auth, nothing is visible until you're logged in. More common for single-tenant/internal servers, where there's no value in advertising "here's what you could do if you signed in" — e.g. a company's internal HR or payroll MCP server, where even knowing the tool names is sensitive.
Our airline server follows the first pattern: tools/list returns both flight_search and flight_reservation to anyone, unauthenticated. It's only when the client actually tries to call flight_reservation that the server pushes back and demands a token.
The full flow
Two participants appear here for the first time: the web browser, which provides the login UI and delivers the authorization code back to the client via redirect, and the Authorization Server, which authenticates the user and mints the token. The login/consent interaction itself is abbreviated — it's standard OAuth 2.1 and shown in full in the second diagram below.
sequenceDiagram
actor User
box rgb(232,244,248) Client Environment
participant Browser as Web Browser
participant Client as MCP Client
(Agent)
end
box rgb(255,243,224) MCP Server Environment
participant MCPServer as MCP Server
(flight tool)
end
box rgb(236,232,248) Authorization Server Environment
participant AS as Authorization Server
end
autonumber 1.1 0.1
Note over Client,MCPServer: Phase 1: MCP Tool discovery
Client->>+MCPServer: tools/list
MCPServer-->>-Client: tools/list result
{ "tools": [
{ "name": "flight_search", ... },
{ "name": "flight_reservation", ... } ] }
// both listed, neither requires a token yet
autonumber 2.1 0.1
Note over User,MCPServer: Phase 2: MCP Tool call — unauthenticated
User->>+Client: "Book flight AA123 for me"
Client->>Client: LLM decides to call
flight_reservation(flight="AA123")
Client->>+MCPServer: tools/call (flight_reservation)
{ "name": "flight_reservation",
"arguments": { "flight": "AA123" } }
// no Authorization header
MCPServer-->>-Client: 401 Unauthorized
WWW-Authenticate: Bearer
resource_metadata="https://flights.example/.well-known/oauth-protected-resource"
autonumber 3.1 0.1
Note over Client,AS: Phase 3: OAuth Metadata Discovery (.well-known)
Client->>+MCPServer: GET /.well-known/oauth-protected-resource
MCPServer-->>-Client: 200 OK
{ "resource": "https://flights.example",
"authorization_servers":
["https://auth.example"] }
Client->>+AS: GET /.well-known/oauth-authorization-server
AS-->>-Client: 200 OK
{ "issuer": "https://auth.example",
"authorization_endpoint": ".../authorize",
"token_endpoint": ".../token",
"registration_endpoint": ".../register" }
rect rgb(250,250,240)
alt client_id already known for https://auth.example (Simply fetch known client_id)
Client->>Client: Load client_id from storage
(local file for desktop agents,
server DB for web clients)
else first contact with this AS (Create the client_id)
Client->>+AS: POST /register
{ "client_name": "my-agent",
"redirect_uris":
["http://127.0.0.1:{port}/callback"] }
AS-->>-Client: 201 Created
{ "client_id": "client_abc" }
Client->>Client: Persist client_id
keyed to issuer https://auth.example
end
end
autonumber 4.1 0.1
Note over Client,AS: Phase 4: OAuth Authorization + token (abbreviated)
Client->>Client: Generate PKCE parameters:
code_verifier = cv_abc (random, 43–128 URL-safe chars),
code_challenge = cc_xyz (BASE64URL(SHA256(cv_abc))),
state = st_123 (random, CSRF protection)
Client->>+Browser: Open system browser at
GET /authorize?response_type=code
&client_id=client_abc
&redirect_uri=http://127.0.0.1:{port}/callback
&code_challenge=cc_xyz&code_challenge_method=S256
&scope=reservations (optional, AS-dependent)
&resource=https://flights.example
&state=st_123
Browser->>+AS: GET /authorize?... (forwarded as-is)
AS-->>-Browser: 200 OK (login page HTML)
Browser->>User: Show login page
Note over User,AS: User logs in and consents.
Abbreviated here — see the
OAuth 2.1 PKCE diagram below
for the full exchange.
AS-->>Browser: 302 Found
Location: http://127.0.0.1:{port}/callback?code=auth_code&state=st_123
Browser->>+Client: GET http://127.0.0.1:{port}/callback?code=auth_code&state=st_123
// the redirect is how the browser hands
// the authorization code back to the client
Client-->>-Browser: 200 OK ("You may close this tab")
deactivate Browser
Client->>+AS: POST /token
{ "grant_type": "authorization_code",
"code": "auth_code",
"code_verifier": "cv_abc",
"redirect_uri": "http://127.0.0.1:{port}/callback",
"client_id": "client_abc" }
AS-->>-Client: 200 OK
{ "access_token": "at_abc",
"refresh_token": "rt_xyz",
"expires_in": 3600 }
autonumber 5.1 0.1
Note over Client,MCPServer: Phase 5: MCP Tool call retry — authenticated
Client->>+MCPServer: tools/call (flight_reservation)
Authorization: Bearer at_abc
{ "name": "flight_reservation",
"arguments": { "flight": "AA123" } }
MCPServer-->>-Client: 200 OK
{ "content": [{ "type": "text",
"text": "{\"confirmation\": \"XJ29K\"}" }] }
Client-->>-User: "Booked! Confirmation code XJ29K."
A note on redirect_uri and the loopback pattern
The redirect_uri in the diagram deserves a closer look — it behaves differently depending on what kind of client is running the flow.
Web app clients (like ChatGPT) already have a real HTTPS server, so they register a fixed hosted URL: https://chatgpt.com/oauth/callback. No local server needed, no port ambiguity.
Native and desktop agents (like Claude Desktop or a CLI tool) don't have a public HTTPS endpoint, so they use the loopback pattern instead: the client picks a free port at runtime, temporarily starts a minimal HTTP server on http://127.0.0.1:{port}/callback, opens the browser with that address as the redirect_uri, and shuts the server down as soon as it catches the authorization code redirect.
OAuth 2.1 only permits loopback (127.0.0.1 or [::1]) as a non-HTTPS redirect_uri for native apps — all other non-HTTPS URIs (including custom schemes like myapp://callback) are disallowed. See RFC 8252 §7.3.
Because native apps can't guarantee a fixed port, RFC 8252 §7.3 includes an explicit allowance: the Authorization Server must compare loopback redirect URIs while ignoring the port number. So a client can register http://127.0.0.1/callback and present http://127.0.0.1:52341/callback at token-exchange time — the AS treats them as matching. The diagram shows {port} as a placeholder to acknowledge a port is chosen at runtime, not hardcoded.
The login/consent exchange in Phase 4 isn't MCP-specific — it's standard OAuth 2.1 with PKCE, and MCP itself doesn't define it. Here's that phase expanded on its own.
Phase 4 expanded: OAuth 2.1 Authorization Code + PKCE
The MCP server doesn't appear here at all. This part of the exchange is only between the user, browser, client, and Authorization Server — proving who the user is and minting a token. The MCP server re-enters the picture only in Phase 5, once the token is presented back to it.
sequenceDiagram
actor User
box rgb(232,244,248) Client Environment
participant Browser as Web Browser
participant Client as MCP Client
(Agent)
end
box rgb(236,232,248) Authorization Server Environment
participant AS as Authorization Server
end
autonumber 4.1 0.1
Note over Client,AS: Phase 4 expanded: OAuth 2.1 Authorization Code + PKCE
Client->>Client: Generate PKCE parameters:
code_verifier = cv_abc (random, 43–128 URL-safe chars),
code_challenge = cc_xyz (BASE64URL(SHA256(cv_abc))),
state = st_123 (random, CSRF protection)
Client->>+Browser: Open /authorize?response_type=code
&client_id=client_abc
&redirect_uri=http://127.0.0.1:{port}/callback
&code_challenge=cc_xyz&code_challenge_method=S256
&scope=reservations&state=st_123
Browser->>+AS: GET /authorize?... (forwarded as-is)
AS-->>Browser: 200 OK (login form)
Browser->>User: Show login form
User->>Browser: Enter username + password
Browser->>AS: POST credentials (implementation specific)
AS-->>Browser: 200 OK (consent screen:
"my-agent wants to make reservations
on your behalf")
Browser->>User: Show consent screen
User->>Browser: Click "Allow"
Browser->>AS: POST consent=approve (implementation specific)
AS-->>-Browser: 302 Found
Location: http://127.0.0.1:{port}/callback?code=auth_code&state=st_123
Browser->>+Client: GET http://127.0.0.1:{port}/callback?code=auth_code&state=st_123
Client-->>-Browser: 200 OK ("You may close this tab")
deactivate Browser
Client->>+AS: POST /token
{ "grant_type": "authorization_code",
"code": "auth_code",
"code_verifier": "cv_abc",
"redirect_uri": "http://127.0.0.1:{port}/callback",
"client_id": "client_abc" }
AS-->>-Client: 200 OK
{ "access_token": "at_abc",
"refresh_token": "rt_xyz",
"expires_in": 3600 }
A couple of things worth calling out about this second diagram: the code_challenge/code_verifier pair (PKCE) exists so that even if the authorization code gets intercepted in transit, it's useless without the verifier the client generated and kept to itself. And the redirect through the browser back to 127.0.0.1:{port}/callback is the actual mechanism by which a native app — which can't easily receive an incoming HTTPS request the way a web server can — gets handed the authorization code at all: the client briefly runs a tiny local server on a loopback port just to catch that one redirect.
Put the two diagrams together and the picture is complete: MCP defines the resource-server side of things — discovery via
.well-knownendpoints, the 401/WWW-Authenticatechallenge, binding the token to a specific resource via theresourceparameter — and leans entirely on existing OAuth 2.1 machinery for everything that happens between the user and the Authorization Server. That division is deliberate: it means any MCP server can plug into whatever identity provider it already trusts, without MCP having to reinvent login and consent screens of its own.
I hope it helps.
0 comments:
Post a Comment