MCP Re-Auth: What ChatGPT Actually Needs When Tokens Expire
Your token expires mid-session. ChatGPT says “please re-authenticate” in plain text instead of launching the OAuth flow. The user copies the message, pastes it into your support channel, and you spend twenty minutes explaining that they need to reconnect. Meanwhile, the same flow works fine in Claude Desktop.
What went wrong isn’t your token management. It’s the signal format.
HTTP 401 isn’t enough at the MCP layer
MCP operates over JSON-RPC 2.0, which means every exchange is an HTTP 200 with a JSON body. The protocol doesn’t use HTTP status codes for application-level errors. A tools/call that fails due to expired credentials still returns 200 OK with a JSON-RPC error object inside.
The intuitive fix is to return an HTTP 401. But MCP clients don’t expect it. The transport layer already succeeded (HTTP 200), and the protocol layer (JSON-RPC) has its own error semantics. Returning a bare 401 or a JSON-RPC error code like -32001 tells the client “something went wrong” but not “you should re-authenticate via OAuth.”
ChatGPT specifically needs a JSON-RPC success response with a special shape:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "Authentication required." }],
"isError": true,
"_meta": {
"mcp/www_authenticate": "Bearer resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource\""
}
}
}
The key is _meta["mcp/www_authenticate"]. This tells ChatGPT: “the token is invalid, here’s where to discover how to get a new one.” ChatGPT reads the resource_metadata URL, fetches the oauth-protected-resource document, finds the authorization_servers array, and walks through the OAuth discovery chain to re-authorize.
Without this exact signal, ChatGPT displays the error message to the user as text. No OAuth popup. No re-auth flow. Just a dead end.
Two layers of auth, two different failure modes
If you’re running an MCP gateway (rather than a single-purpose server), you have two distinct authentication boundaries:
Gateway auth is between the MCP client and your gateway. The client presents an OAuth token (usually a JWT) that proves who the user is. This is the token that ChatGPT can refresh — it issued the token, it controls the OAuth flow, and the _meta signal tells it to restart that flow.
Integration auth is between your gateway and downstream APIs — Jira, Salesforce, GitHub, Google. These tokens were granted by the user during a separate OAuth flow (typically through your dashboard). The MCP client has no relationship with these providers and can’t refresh their tokens.
When a Jira token expires, ChatGPT can’t fix it. The user authorized Jira through your dashboard, not through ChatGPT’s OAuth flow. The right response is a clear error message telling the user to reconnect via the dashboard — not a re-auth signal that would confuse the client.
This distinction matters for implementation:
| Layer | Token owner | Can MCP client fix it? | Correct response |
|---|---|---|---|
| Gateway auth | MCP client (ChatGPT) | Yes | _meta re-auth signal |
| Integration auth | User (via dashboard) | No | isError with reconnect message |
How we handle gateway auth failures
When authenticateRequest throws for a reauthable reason (missing bearer, invalid token, verification failure, missing subject claim), we return the _meta signal:
const reauthable = ["missing_bearer", "invalid_token", "auth_failed", "missing_sub"];
if (code && reauthable.includes(code)) {
res.json(mcpReauthResult(id, req, e?.message ?? "Authentication required."));
} else {
// Non-reauthable failures (bad API key, no tenant) stay as JSON-RPC errors
res.json(err(id, -32001, "Unauthorized", e?.message));
}
The mcpReauthResult function builds the response dynamically, constructing the resource_metadata URL from the request’s forwarded headers. This handles both single-tenant deployments (resource at the service root) and multi-tenant deployments (resource scoped to the tenant slug).
Non-reauthable failures like invalid_api_key or no_tenant still return standard JSON-RPC errors. There’s no OAuth flow that can fix a bad API key — the user needs to generate a new one.
How we handle integration auth failures
For downstream API failures, the pattern is: retry once after refreshing the token, then throw a typed error if the retry also fails.
let resp = await doFetch();
if (resp.status === 401) {
try {
await withRefreshMutex(key, () => refreshOAuthToken(tenantId, uid, provider, config));
} catch {
throw new IntegrationAuthError(provider, 401);
}
resp = await doFetch();
if (resp.status === 401) throw new IntegrationAuthError(provider, 401);
}
withRefreshMutex deduplicates concurrent refresh attempts — if three tool calls hit 401 simultaneously, only one refresh request goes to the provider. The IntegrationAuthError is caught at the MCP handler level and returned as a tool result with isError: true but no _meta signal:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{
"type": "text",
"text": "Jira authentication failed (HTTP 401). Please reconnect Jira via the dashboard."
}],
"isError": true
}
}
No _meta means ChatGPT displays the message to the user as text, which is the correct behavior — the user needs to take action in the dashboard, not in ChatGPT.
For providers without refresh tokens (GitHub, Slack, Notion), we skip the retry and throw IntegrationAuthError immediately on 401.
Spec implications
The _meta["mcp/www_authenticate"] pattern isn’t in the core MCP specification yet. It emerged from ChatGPT’s implementation and was confirmed via OpenAI’s developer support channels. Claude Desktop handles re-auth differently. Other MCP clients may not recognize it at all.
But the architectural principle is sound: when a protocol operates over a transport that doesn’t expose HTTP semantics, the protocol needs its own mechanism for authentication challenges. The WWW-Authenticate header has served HTTP well for decades. _meta["mcp/www_authenticate"] is the JSON-RPC equivalent.
Gateways are the right place for this translation. Individual MCP tool servers shouldn’t need to know about ChatGPT’s re-auth protocol. The gateway sits at the authentication boundary, verifies tokens, and speaks the right dialect back to whatever client connected. If the spec eventually standardizes this pattern, the gateway is where compliance happens once rather than in every tool server.
For now, the pattern works. Tokens expire, ChatGPT pops the OAuth flow, and the user continues without leaving the conversation. That’s the experience that should be the default.