What an MCP Client Has to Do When a Remote MCP Server Returns 401
You add a remote MCP server by its URL, connect, and get 401 Unauthorized. That isn’t a dead end; it’s the server asking you to run OAuth. Find its authorization server, register a client, send the user through an authorization code flow with PKCE, exchange the code for tokens, and keep those tokens fresh. Here is each step in Python with httpx, adapted from the client I rewrote last week, and the places it broke.
Treat the 401 as the start of the flow
The MCP authorization spec has the client begin OAuth when a server answers 401. In my client, a server added without any auth that answers its first ping with 401 is switched to OAuth on the spot, and Connect returns an authorize URL instead of an error.
1. Find the authorization server
The current spec (2025-11-25) says clients must discover the authorization server through Protected Resource Metadata (RFC 9728): read resource_metadata from the 401’s WWW-Authenticate header, or fetch /.well-known/oauth-protected-resource, then follow its authorization_servers to the server’s RFC 8414 or OpenID Connect metadata.
My client does the older, shorter version. It assumes the MCP server is its own authorization server and asks it for RFC 8414 metadata directly, first under the MCP URL, then at the root of the host. With no metadata, it falls back to the default paths of the 2025-03-26 spec:
from urllib.parse import urlparse
import httpx
async def discover(mcp_url: str) -> dict:
p = urlparse(mcp_url)
base = f"{p.scheme}://{p.netloc}"
for url in (f"{mcp_url.rstrip('/')}/.well-known/oauth-authorization-server",
f"{base}/.well-known/oauth-authorization-server"):
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url)
if r.status_code == 200:
return r.json()
except (httpx.HTTPError, ValueError):
continue
return {"authorization_endpoint": f"{base}/authorize",
"token_endpoint": f"{base}/token",
"registration_endpoint": f"{base}/register"}
That works for servers that host their own OAuth endpoints, which covers the MCP-native servers I had connected so far. It can’t find an authorization server on another host; for that you need the RFC 9728 step.
2. Register the client (RFC 7591)
With no client ID on file, register one. Pick the token endpoint auth method from what the server lists, and keep what you asked for if the response doesn’t echo it:
async def register(meta: dict, redirect_uri: str) -> dict | None:
supported = meta.get("token_endpoint_auth_methods_supported") or [
"client_secret_basic", "client_secret_post", "none"]
method = next((m for m in ("client_secret_basic", "client_secret_post")
if m in supported), "none" if "none" in supported else supported[0])
body = {"client_name": "My MCP Client",
"redirect_uris": [redirect_uri],
"grant_types": meta.get("grant_types_supported") or ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": method}
if meta.get("scopes_supported"):
body["scope"] = " ".join(meta["scopes_supported"])
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(meta["registration_endpoint"], json=body)
if r.status_code not in (200, 201):
return None
reg = r.json()
reg.setdefault("token_endpoint_auth_method", method)
return reg
Save the client ID and secret and the endpoints. Rows I had saved without the token endpoint could never be refreshed; my client now rediscovers once and writes them back.
The 2025-11-25 spec makes dynamic registration optional, kept for compatibility, and prefers pre-registered clients and Client ID Metadata Documents. Where the OAuth client is created in the provider’s console (Google, GitHub and Slack for me), my client skips registration and uses the user’s client ID and secret, with a preset for each provider’s endpoints.
3. Authorize with PKCE and the resource indicator
PKCE with S256, a random state, the scopes, and the MCP server’s URL as resource (RFC 8707):
import base64, hashlib, secrets
from urllib.parse import urlencode
def pkce_pair():
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
return verifier, challenge
verifier, challenge = pkce_pair()
state = secrets.token_urlsafe(32)
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
if scopes:
params["scope"] = " ".join(scopes)
if resource:
params["resource"] = resource # the MCP server's URL
authorize_url = meta["authorization_endpoint"] + "?" + urlencode(params)
Store the verifier, client ID, token endpoint and auth method under state. The callback looks the flow up by state and uses exactly those values, so an edit to the server’s settings in between can’t break the exchange.
Send a scope: the user’s configured scopes, else what the registration response granted, else the metadata’s scopes_supported. RFC 6749 lets a server refuse a request without one. The spec requires resource on both the authorize and token requests; my client sends it to servers it registered with dynamically and leaves it off for the hand-configured Google, GitHub and Slack clients.
4. Exchange the code
The token request is form-encoded. How the client authenticates depends on the method from step 2:
async def exchange(code, verifier, *, token_endpoint, client_id, client_secret,
method, redirect_uri, resource=None):
body = {"grant_type": "authorization_code", "code": code,
"redirect_uri": redirect_uri, "code_verifier": verifier,
"client_id": client_id}
if resource:
body["resource"] = resource
headers = {}
if client_secret and method == "client_secret_basic":
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
headers["Authorization"] = f"Basic {cred}"
elif client_secret and method == "client_secret_post":
body["client_secret"] = client_secret
# "none": a public client; PKCE is the proof
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(token_endpoint, data=body, headers=headers)
r.raise_for_status()
return r.json()
When the exchange fails, show the user the provider’s error text; a redirect URI mismatch, a missing secret and a scope problem otherwise look the same.
5. Store the tokens and refresh before they expire
from datetime import datetime, timedelta, timezone
def expires_at(tokens):
seconds = int(tokens.get("expires_in") or 3600) # no expires_in: assume an hour
return datetime.now(timezone.utc) + timedelta(seconds=seconds)
def needs_refresh(exp, window=timedelta(minutes=5)):
return exp is not None and exp <= datetime.now(timezone.utc) + window
Check before every connection, including when you reuse a cached client. A refresh is the same token request with grant_type=refresh_token, the same client authentication and the same resource. Then:
- Keep the old refresh token if the response has no new one; not every server rotates them.
- If a refresh fails while the current token still has a few minutes left, keep using it.
- Google returns a refresh token only with
access_type=offlineon the authorize URL; my Google preset also sendsprompt=consentso that connecting again returns one too.
The client itself only needs the access token. With FastMCP, Client(mcp_url, auth=access_token) sends it as a bearer token.
6. A 401 after you’re connected
If there’s no access token and no refresh works, my client marks the server inactive and returns a 401 to the app, and the next Connect starts a fresh authorization flow. A 401 during a tool call comes back as a tool error that says to reconnect, not as a hang. My client doesn’t refresh and retry the call; the five-minute window is there to make that case rare.
Pitfalls
- JSON that arrives as a string. Stored config and tokens can come back as JSON strings, and
use_oauthas"true". Parse both forms. - “Has tokens” is not “has an access token.” My pending PKCE state lived in the same column as the tokens, so a half-finished flow looked like a stored token. Check for
access_tokenitself. - Don’t mark a server connected on an unauthenticated ping. Wait for a real access token and a ping made with it.
How to verify
curl -s https://your-mcp-server/.well-known/oauth-authorization-server \
| jq '{authorization_endpoint, token_endpoint, registration_endpoint,
code_challenge_methods_supported, scopes_supported}'
Check that your authorize URL carries code_challenge_method=S256, state, scope and resource, and that the verifier is 43 characters without = padding. Unit-test what needs no network: the PKCE pair, the authorize URL, Basic header versus secret in the body, and the refresh window.
What my client doesn’t do yet
Against 2025-11-25: no Protected Resource Metadata and no WWW-Authenticate parsing (so no scope taken from the challenge), no OpenID Connect discovery, no Client ID Metadata Documents, no check of code_challenge_methods_supported before starting (the spec says to refuse without it), and no step-up when a server answers 403 insufficient_scope.
How I ran into it
I hit this connecting Catalyst, my AI workspace, to a Gmail MCP server. Connect succeeded and the tools listed, but every tool call hung for the full 30-second timeout and then came back 401. My OAuth code assumed every server worked like the MCP-native ones: dynamic registration, scopes advertised in the metadata, and the resource parameter. Google’s doesn’t. You create the OAuth client in Google’s console and name the scopes yourself, so my code authorized with no scopes. The row ended up holding only pending PKCE state, the backend built an unauthenticated client from it, and the server was marked active anyway. The rewrite above is what came out of that.