{"slug": "why-claude-code-mcp-servers-keep-asking-you-to-sign-in-again", "title": "Why Claude Code MCP servers keep asking you to sign in again", "summary": "Running 40-60 concurrent Claude Code sessions against the same OAuth-protected remote MCP servers causes a refresh-token rotation race that repeatedly forces re-authentication, according to a DocSpring engineer's blog post. The MCP authorization specification (2025-11-25) requires public clients to rotate refresh tokens, and RFC 9700 lets servers revoke the active refresh token when a spent one is reused; a Shortcut server log from 8 November recorded \"Refresh lock held by another process\" 169 times, \"Token refresh failed, returning current tokens\" 143 times, and \"Skipping connection (cached needs-auth)\" 138 times. The author resolved it by routing all sessions through a single local MCPProxy process that signs in once per server and refreshes tokens for every session, eliminating the repeated sign-ins.", "body_md": "I usually have 40-60 Claude Code sessions open at once, each working on its own branch. Most of them use the same remote MCP servers: Shortcut for our stories, Notion for docs, Intercom for support conversations, Sentry for errors, and Slack. Every one of those servers signs you in with OAuth.\n\nMany of my sessions would lose their sign-in to at least one server, and this would happen many times per day. Claude Code showed \"needs authentication\" next to Shortcut or Notion, I'd sign in again in one session, and the others would stay broken until I reconnected each one by hand. A few hours later it would happen again.\n\nIt turned out to be a race between the sessions, and adding more sessions made it worse. The fix was to stop letting each session hold its own copy of the tokens. Now one local process, [MCPProxy](https://github.com/smart-mcp-proxy/mcpproxy-go?ref=docspring.com), signs in to each server once and refreshes the tokens for everyone, and every Claude Code session connects to it instead. I haven't had to sign in again since. I've seen plenty of people talking about this on X, so I thought I would write a blog post to cover what causes it, what we changed in MCPProxy, and how my sessions use it.\n\n## Why the sessions sign each other out\n\nWhen you sign in to a remote MCP server, Claude Code gets two tokens. The access token is what it sends with each request, and it expires after a while: hours for some servers, much less for others. The refresh token is what Claude Code uses to get a new access token without opening the browser again.\n\nThe MCP specification requires refresh token rotation for clients like Claude Code. Its [authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization?ref=docspring.com) says \"For public clients, authorization servers MUST rotate refresh tokens\". Rotation means every refresh hands back a new refresh token and invalidates the old one, so each refresh token works once. Slack's [token rotation docs](https://docs.slack.dev/authentication/using-token-rotation?ref=docspring.com) put it plainly: \"Refresh tokens are designed to be used once.\"\n\nRotation also lets a server spot a stolen token. If an old refresh token turns up again, someone else has a copy. [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700?ref=docspring.com#section-4.14.2), the OAuth security best practices, says the server can't tell which party is the attacker, so \"it will revoke the active refresh token.\" Some servers revoke the whole sign-in when that happens.\n\nNow put 47 Claude Code processes on one Mac. They all read the same stored sign-in for Shortcut, and when its access token expires, many of them notice within the same few seconds:\n\n1. The first session to refresh gets a new pair of tokens and saves them. The old refresh token is now spent.\n2. Other sessions refresh with the spent token. The server answers `invalid_grant` , and it may treat the reuse as theft and revoke the new tokens too.\n3. A session that gets `invalid_grant` decides the sign-in is gone and clears the stored tokens, which can wipe out the good tokens the first session just saved.\n4. Each session remembers that the server needs authentication. Signing in again in one session doesn't fix the others until they reconnect.\n\nClaude Code does try to prevent this. It takes a lock file before refreshing, waits if another process holds it, and checks whether another process has already saved fresh tokens. That didn't hold up with dozens of sessions. Claude Code writes a log for each MCP server, and on 8 November the one for Shortcut showed how often the lock lost:\n\n- \"Refresh lock held by another process\": 169 times\n- \"Token refresh failed, returning current tokens\": 143 times\n- \"Skipping connection (cached needs-auth)\": 138 times\n- \"Token refresh failed with invalid_grant\", followed by \"Invalidated credentials\", with dozens of sessions refreshing within the same few seconds\n\nA session that waits five times for the lock gives up and keeps its expired token, and a session that loses the race clears the sign-in. Shortcut failed most often because its access tokens expire quickly, so the race came up many times a day. Notion's last longer, so it broke less often.\n\nOther people have reported the same race. [Issue #84440](https://github.com/anthropics/claude-code/issues/84440?ref=docspring.com) reproduces it with five `claude mcp get` commands run at once against a server that rotates refresh tokens, and shows the winning session's new token being stored and then wiped. It was closed as not planned in September, as was [#43000](https://github.com/anthropics/claude-code/issues/43000?ref=docspring.com), \"MCP OAuth with multiple terminals open causes re-auth\". Claude Code 2.1.325 logs messages about keeping tokens that another process rotated, so it may handle this better now. In those issues, the only workaround anyone reported was running fewer sessions.\n\n## One process holds the tokens\n\nIf only one process ever refreshes a token, there's nothing to race. That process has to be something that runs all the time, holds the sign-in for every server, and lets every Claude Code session use those servers through it.\n\nMCPProxy does this. It's an open-source (MIT) MCP proxy written in Go: one binary that runs in the background on your machine, with a web UI and an optional macOS menu-bar app. You add your MCP servers to it once and sign in to each one through MCPProxy, which keeps the tokens in its own data directory. Its docs say refreshes are \"serialized per server\": however many requests notice an expiring token at once, MCPProxy sends one refresh to the server, and every waiting request gets the result.\n\nClaude Code then talks to MCPProxy at `http://127.0.0.1:8080` instead of talking to Shortcut or Notion. Each session authenticates to MCPProxy with a static key, which never expires and never needs refreshing, so 47 sessions can share it safely. The OAuth tokens never leave MCPProxy.\n\n## What we changed in our fork\n\nOut of the box, MCPProxy puts all your servers behind one endpoint and exposes them as a small set of its own tools. An agent calls `retrieve_tools` to search across every server, then calls the tool it found through `call_tool_read`, `call_tool_write` or `call_tool_destructive`. There's also a mode that lists every tool directly, renamed to `server__tool`.\n\nBoth would have changed the name of every tool my sessions use. Claude Code names MCP tools after the server they come from, such as `mcp__shortcut__get_story`, and those names are all over our setup: permission rules that allow `mcp__shortcut__*`, skills that say which tool to call, and the notes in our `AGENTS.md` files. I wanted the sessions to see exactly what they saw before, with only the sign-in moved into MCPProxy.\n\nSo we [forked MCPProxy](https://github.com/DocSpring/mcpproxy-go/tree/docspring/per-server-endpoints?ref=docspring.com) and made a few changes.\n\n### One URL per server\n\n`/mcp/s/<server>` serves a single upstream server under its own tool names and with its own instructions, as if Claude Code had connected to it directly. `http://127.0.0.1:8080/mcp/s/shortcut` lists `get_story`, not `shortcut__get_story`. You register it in Claude Code as `shortcut`, and the tools stay `mcp__shortcut__get_story`. Each server is still a separate entry that you can turn off or reconnect on its own in `/mcp`. The endpoint uses MCPProxy's existing authentication, tool filters and quarantine, so a key that isn't allowed a server gets a 404 for it.\n\n### Usage stats without fetching every tool list\n\nThe menu-bar app asks MCPProxy for usage stats about every 35 seconds. To show how many tokens it saves, MCPProxy fetched the tool list from every server for each of those requests, then counted the tokens in every tool definition. With twelve servers that was about 1,000 calls an hour to the servers and 3 to 7 seconds per poll, and minutes when my Mac was busy. Now it uses the tool lists it already keeps and only recounts when a server's tools change. The poll takes about 2 milliseconds.\n\n### Health checks that tolerate a busy machine\n\nMCPProxy pings each server every 30 seconds and used to give up on a server after three pings that took longer than 5 seconds. When I restart all my sessions at once, my Mac used to be overloaded for a few minutes, every ping was slow, and MCPProxy would disconnect and reconnect every server while sessions were in the middle of requests. A ping now gets 15 seconds, and a server needs five slow pings in a row before it's marked as failing. A refused connection or a DNS failure still counts straight away. (I also fixed this in my Herdr config by pausing for a second after resuming each Claude Code session, so that they don't resume all at once.)\n\n## Setting it up for Claude Code\n\nThis is my setup on macOS. The upstream MCPProxy is on Homebrew, but the per-server URLs need our fork, so I build it from source:\n\n```\ngit clone -b docspring/per-server-endpoints https://github.com/DocSpring/mcpproxy-go.git\ncd mcpproxy-go\nmake build\ncp mcpproxy ~/.local/bin/mcpproxy-fork\n```\n\nThe build embeds the web UI, so it needs Node.js 22.18 or later as well as Go.\n\n### Lock it down\n\nBy default, MCPProxy's MCP endpoint doesn't ask for a key, so any process running as you could call your servers with your sign-ins. I turned on `require_mcp_auth` and kept it on the loopback address in `~/.mcpproxy/mcp_config.json`:\n\n```\n{\n  \"listen\": \"127.0.0.1:8080\",\n  \"require_mcp_auth\": true\n}\n```\n\nRequests without a valid key now get a 401.\n\nTo keep it running, I added a launchd agent at `~/Library/LaunchAgents/app.mcpproxy.core.plist` that starts it at login and restarts it if it exits:\n\n```\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>Label</key>\n  <string>app.mcpproxy.core</string>\n  <key>ProgramArguments</key>\n  <array>\n    <string>/Users/you/.local/bin/mcpproxy-fork</string>\n    <string>serve</string>\n  </array>\n  <key>RunAtLoad</key>\n  <true/>\n  <key>KeepAlive</key>\n  <true/>\n</dict>\n</plist>\nlaunchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.mcpproxy.core.plist\n```\n\n### Add each server and sign in once\n\n```\nmcpproxy-fork upstream add shortcut https://mcp.shortcut.com/mcp --transport http --trust-mode auto\nmcpproxy-fork auth login --server=shortcut\n```\n\nMCPProxy quarantines a new server until you approve it in the web UI, and with the default trust mode it holds back new or changed tools until a scan says they're safe. I set `--trust-mode auto` for vendors' own hosted servers, which I'd be trusting just as much if Claude Code connected to them directly. Keep the defaults for anything you're less sure of.\n\n`auth login` opens the server's sign-in page in your browser. That's the only time you sign in. When a sign-in does need renewing, run `auth login` again or use the web UI at `http://127.0.0.1:8080/ui/`, and every session is fixed at once.\n\n### Give Claude Code a key\n\nCreate an agent token that can use every server and never expires, and store it in the macOS Keychain:\n\n```\nmcpproxy-fork token create --name claude-code --servers \"*\" \\\n  --permissions read,write,destructive --expires never\nsecurity add-generic-password -U -a mcpproxy -s mcpproxy-claude-code -w 'mcp_agt_...'\n```\n\nClaude Code's `headersHelper` setting runs a command each time it connects to a server and sends the headers the command prints. Mine reads the key from the Keychain, so the key isn't written into any Claude Code config file:\n\n``` bash\n#!/bin/sh\n# ~/.local/bin/mcpproxy-claude-headers\nset -eu\ntoken=$(security find-generic-password -a mcpproxy -s mcpproxy-claude-code -w)\nprintf '{\"Authorization\":\"Bearer %s\"}\\n' \"$token\"\n```\n\nThen register each server at user scope under its usual name:\n\n```\nclaude mcp add-json --scope user shortcut \\\n  '{\"type\":\"http\",\"url\":\"http://127.0.0.1:8080/mcp/s/shortcut\",\"headersHelper\":\"/Users/you/.local/bin/mcpproxy-claude-headers\"}'\n```\n\nWhen the helper prints an `Authorization` header, Claude Code uses it and doesn't try OAuth for that server. Remove any older registration of the same server in a project's `.mcp.json` too. Otherwise Claude Code warns about conflicting scopes, and some sessions keep talking to the server directly.\n\n### Servers that don't let MCPProxy register itself\n\nMost servers let a new client register itself during sign-in, using Dynamic Client Registration. Slack doesn't: its [MCP server docs](https://docs.slack.dev/ai/slack-mcp-server?ref=docspring.com) say \"We do not support SSE-based connections or Dynamic Client Registration at this time\", and only internal apps or apps in the Slack Marketplace can use it. Slack also allows only two active tokens at a time, so the official Slack plugin, which signs in separately in every session, kept getting signed out too.\n\nFor Slack, I created an internal Slack app, turned on \"Slack MCP server access\" in its settings, added a fixed redirect URL, and gave MCPProxy the app's client ID and secret. MCPProxy already supports this, and it can read the secret from the Keychain:\n\n```\n{\n  \"name\": \"slack\",\n  \"url\": \"https://mcp.slack.com/mcp\",\n  \"protocol\": \"http\",\n  \"oauth\": {\n    \"client_id\": \"1234567890.1234567890123\",\n    \"client_secret\": \"${keyring:slack-oauth-client-secret}\",\n    \"redirect_uri\": \"http://localhost:3119/callback\",\n    \"scopes\": [\"channels:history\", \"channels:read\", \"chat:write\", \"search:read.public\", \"users:read\"]\n  }\n}\n```\n\n## Things to watch out for\n\n### Restarting MCPProxy reconnects every session\n\nWhen MCPProxy restarts, every session loses its connections and reconnects at the same moment, and on a busy machine many of those connections time out. To pick up a config change for one server, restart only that server with `mcpproxy-fork upstream restart <name>`.\n\n### A busy Keychain can make a server look signed out\n\nMCPProxy checks that the Keychain is available when it starts and gives up after 2 seconds. During one of those reconnect rushes, the check failed, a server's `${keyring:...}` header wasn't filled in, and that server showed \"Sign-in required\" even though nothing was wrong with its key. Restarting that one server fixed it.\n\n### The menu-bar app can start its own copy\n\nIf the menu-bar app starts at login before your own MCPProxy, it launches the core bundled inside the app on port 8080. Our fork then couldn't get the port, and for three days my sessions were back on the upstream version without any warning, with `retrieve_tools` in place of their own tools. I removed the app from my login items. If you use it, open it after MCPProxy is running and it connects to that one instead, or start it with `MCPPROXY_TRAY_SKIP_CORE=1`.\n\n### Some errors from a server get hidden\n\nWhen Slack rejected the connection with a 400 (\"App is not enabled for Slack MCP server access\"), MCPProxy reported it as \"likely a legacy SSE server\", because its MCP library describes every 4xx response to the first request that way. Calling the server directly with `curl` showed the real error.\n\n## Do you need this?\n\nIf you only run a few Claude Code sessions at a time, Claude Code's own lock is probably enough. I only needed this because I run many sessions in parallel. Since 9 November I've signed in to each server once, my sessions use the same tool names as before, and I can open as many sessions as my Mac can handle without them signing each other out.", "url": "https://wpnews.pro/news/why-claude-code-mcp-servers-keep-asking-you-to-sign-in-again", "canonical_source": "https://docspring.com/blog/posts/claude-code-mcp-oauth-reauthentication/", "published_at": "2026-11-13 23:02:00+00:00", "updated_at": "2026-11-13 23:21:56.650991+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools"], "entities": ["Claude Code", "MCPProxy", "Shortcut", "Notion", "Intercom", "Sentry", "Slack", "DocSpring"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/why-claude-code-mcp-servers-keep-asking-you-to-sign-in-again", "markdown": "https://wpnews.pro/news/why-claude-code-mcp-servers-keep-asking-you-to-sign-in-again.md", "text": "https://wpnews.pro/news/why-claude-code-mcp-servers-keep-asking-you-to-sign-in-again.txt", "jsonld": "https://wpnews.pro/news/why-claude-code-mcp-servers-keep-asking-you-to-sign-in-again.jsonld"}}