{"slug": "the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the", "title": "The MCP Session Refresh Gotcha: Why \"Token Expired\" and \"Session Expired\" Are Not the Same Bug", "summary": "A developer's analysis of MCP issue trackers across Copilot CLI, Gemini CLI, Cursor, Open WebUI, Codex and homegrown gateways finds that most \"token expired\" failures stem from conflating two independent clocks: OAuth access-token expiry (HTTP 401, fixed by a token refresh) and MCP session lifetime (HTTP 404, fixed by re-initializing). The post documents a real rmcp-based server returning 401 \"Session not found\" for an unknown session ID, which caused OAuth clients like claude.ai and Claude Code to discard valid tokens and force interactive re-auth, and notes that refreshing a bearer token while reusing an old Mcp-Session-Id produces a \"Session token does not match bearer token\" mismatch. The recommended fixes are to return 404 for unknown sessions and 401 with WWW-Authenticate only for genuine credential problems, and for clients to check for that header before discarding credentials.", "body_md": "Scan enough MCP issue trackers and you'll notice the same shape of bug filed against a dozen different clients and servers: Copilot CLI, Gemini CLI, Cursor, Open WebUI, Codex, and several homegrown gateways. The symptom is always some version of \"it worked, then it silently stopped working, and the only fix was to log in again.\"\n\nThe root cause is almost never \"OAuth is broken.\" It's that two independent clocks — the **access token's** expiry and the **MCP session's** lifetime — get treated as one clock, and the recovery logic for one gets applied to the other.\n\nThat distinction is the entire subject of this post.\n\nThis post is narrowly about **keeping a connection alive after the initial handshake** — refreshing tokens and re-initializing sessions without forcing a user back through a browser. It assumes you already have PKCE, discovery, and initial token validation working (covered in an earlier post in this series). It does **not** cover setting up dynamic client registration or scopes — that's a separate problem with a separate fix.\n\nOAuth token expiry and session expiry are unrelated. An expired token gives you 401 and needs a refresh; an expired session gives you 404 and needs a re-initialize. The session itself, per spec, is a correlation and state handle. It carries no lease, no expiry timestamp and no promise about how long it stays valid. Nothing in the lifecycle specification obliges a server to keep it alive for any period at all.\n\nSo you're managing two independent failure modes with two independent recoveries, and most of the bugs above come from collapsing them into one.\n\nThe spec's intent is clean: If a server has ended a session, requests carrying the old id must receive HTTP 404. The client's required response is to discard the id and send a fresh initialize. A 401 means something entirely different — the MCP server acts as an OAuth resource server. The client sends a bearer token on each request, the server validates expiry, scope and audience, and an invalid token gets a 401.\n\nIn practice, plenty of servers don't respect this. One real-world rmcp-based server was found answering an unrecognized session ID with 401 Unauthorized: Session not found instead of the spec-mandated 404 — and the consequence is severe: OAuth MCP clients (claude.ai, Claude Code) treat a 401 as an invalid credential: they drop the token and demand an interactive re-auth. A perfectly valid, unexpired token gets discarded because the server picked the wrong status code for a session problem.\n\n**If you're building the server**, this is the single highest-leverage fix in this whole post: return 404 for unknown/expired sessions, 401 (with `WWW-Authenticate`) only for actual credential problems, and never conflate the two.\n\n**If you're building the client**, don't treat every 401 as \"burn the token and force login.\" Distinguish an auth failure from a stale session by checking whether `WWW-Authenticate` is present, and only discard credentials on a genuine 401 with that header.\n\nThis is where a surprising number of teams get stuck before refresh logic even runs. Several identity providers don't issue a refresh token unless you explicitly ask:\n\nThe fix at the client level: check for a `refresh_token` field immediately after the initial grant, and fail loudly in development if it's missing — don't wait until a user hits it in production three hours into a session.\n\nThis is the gotcha most teams never see coming, because the token refresh itself succeeds. A Streamable HTTP transport ties a session ID to the credentials that created it. If you refresh the bearer token but keep reusing the old `Mcp-Session-Id`, the server sees a mismatch between the session and the token attached to it.\n\nThis exact failure was reported against a production MCP integration: after Cursor performs a silent OAuth access-token refresh (near token expiry / refresh flow), the result was Session token does not match bearer token. The only reliable recovery was heavy-handed: Note that only toggling the MCP server off/on, full re-login, or restarting Cursor recovers it. Manual testing confirmed the actual fix is much lighter than a full restart: refreshing the OAuth access token and calling initialize again with the new bearer produces a new Mcp-Session-Id and restores tool calls. Cursor's silent refresh does the token part but skips session re-initialization.\n\n**Rule of thumb: a successful token refresh is not a completed recovery on its own.** After any refresh, re-run `initialize` with the new bearer token and accept whatever session ID comes back. Don't assume the old session ID is still valid just because the token now is.\n\nThe last gotcha is about failure discipline. One gateway logged this pattern in production: An MCP server whose credential was never saved is retried forever. Backoff grows to 300 s and then stays there; 18+ failures logged in one session with no circuit breaker, no UI indicator. A 401 caused by a genuinely dead credential is not a transient network blip — retrying it indefinitely just burns cycles and hides the real problem from whoever's on call.\n\nThe pattern that actually works, synthesized from several fixes above:\n\n`initialize`, then retry the original call once.\nNone of these five phases are exotic — they're each a handful of lines. But they interact, and the bug reports above show that even mature clients (Cursor, Gemini CLI, Copilot CLI) shipped with at least one of these five phases missing or wrong, and the resulting incident always looked the same to the end user: \"the MCP server just stopped working.\"\n\nIf you're running MCP servers in production, this class of failure — auth working perfectly for hours, then a session or refresh edge case taking the whole integration down with a vague error — is exactly the kind of incident worth documenting *before* it happens, not after. Our [AI Agent Incident Postmortem & Permission-Scoping Template Pack](https://quietdesk15.gumroad.com/l/wytfec) gives you a structured way to capture root cause, blast radius, and the permission/session boundaries involved, so the next time a token refresh gotcha slips through, you're writing a five-minute postmortem instead of re-deriving the timeline from scratch.\n\n*Written with AI assistance and reviewed for accuracy.*", "url": "https://wpnews.pro/news/the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the", "canonical_source": "https://dev.to/quietdesk_studio_83466628/the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the-same-bug-4dmn", "published_at": "2026-09-29 20:44:00+00:00", "updated_at": "2026-09-29 20:46:33.784314+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["MCP", "Copilot CLI", "Gemini CLI", "Cursor", "Open WebUI", "Codex", "claude.ai", "Claude Code"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the", "markdown": "https://wpnews.pro/news/the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the.md", "text": "https://wpnews.pro/news/the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the.txt", "jsonld": "https://wpnews.pro/news/the-mcp-session-refresh-gotcha-why-token-expired-and-session-expired-are-not-the.jsonld"}}