Debugging Claude Code and Google MCP Quirks
DEV Community

Debugging Claude Code and Google MCP Quirks

This article provides a step by step investigation of why Claude Code needs a fresh Google sign-in every hour for the Google Workspace remote MCP servers. Each step is a command and its output, from the stored token to the request Claude Code sends to what Google's authorization server accepts, followed by the alternatives and what each one costs to run. https://github.com/xbill9/workspace-mcp-claude Claude Code requests a refresh token with the offline_access scope, and only when the authorization server lists that scope. Google lists it nowhere and rejects it as invalid_scope . Google issues refresh tokens through its own access_type=offline parameter, and with that one parameter the same sign-in returns a refresh token that renews without a browser. What is this article about? The companion article sets up Claude Code with Google's eight Workspace MCP servers (Gmail, Drive, Docs, Sheets, Slides, Calendar, Chat and People) and lists two sign-in limits: MCP Configuration for Google Workspace with Claude Code This one takes the first limit apart. Every sign-in lasts about an hour, then the server's tools disappear from Claude Code until you sign in again. Access Tokens and Refresh Tokens An OAuth sign-in hands the app an access token. Google's access tokens last one hour, for every app. Apps that stay signed in for days also hold a refresh token. When the access token expires, the app trades the refresh token for a new access token, with no browser and no user. So a one-hour access token is normal. A one-hour sign-in means the app has no refresh token. At This Point You Should Have… - The Workspace MCP servers registered in Claude Code, as in the companion article. - The repository cloned, for mcp_status.sh ,oauth_probe.py andscope_check.sh . - curl and Python 3. This article used Claude Code 2.1.291. Step 1 - Check What Claude Code Stored mcp_status.sh --verify reads each server's stored sign-in and asks Google whether the token is still good: ./mcp_status.sh --verify server registered token min left refresh gmail yes valid 59 no drive yes valid 60 no The last column is the whole problem. Google issued an access token with no refresh token, so Claude Code has nothing to renew with when the 60 minutes run out. Step 2 - Read Claude Code's Sign-in Request claude mcp login gmail prints the URL it sends to Google (client ID, PKCE challenge and state shortened here): https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id= &code_challenge=…&code_challenge_method=S256&redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback&state=…&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.readonly+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.compose&resource=https%3A%2F%2Fgmailmcp.googleapis.com%2Fmcp%2Fv1 The request carries response_type , client_id , PKCE, redirect_uri , state , scope and resource . It has no offline_access scope and no access_type parameter. Step 3 - When Claude Code Asks for a Refresh Token The MCP specification answers this in SEP-2207, "OIDC-Flavored Refresh Token Guidance". A client that wants a refresh token adds the offline_access scope when the authorization server's metadata lists offline_access in scopes_supported . The same guidance tells MCP servers to leave offline_access out of their own metadata, because a refresh token is a matter between the client and the authorization server. SEP-2207: OIDC-Flavored Refresh Token Guidance Claude Code 2.1.291 implements it as one function: function oms(e,t){if(e!==null&&e.split(" ").includes("offline_access"))return e;if(!t?.scopes_supported?.includes("offline_access"))return e;return e===null?"offline_access":${e} offline_access} e is the scope string and t the authorization server metadata. If the scopes already include offline_access , they go out unchanged. If the metadata does not list it, they go out unchanged. Otherwise offline_access is appended. offline_access comes from OpenID Connect, where it "requests that an OAuth 2.0 Refresh Token be issued": OpenID Connect Core 1.0, §11 Offline Access Step 4 - What Google's Metadata Lists The Gmail MCP server names accounts.google.com as its authorization server: curl -s https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1 | jq "{resource, authorization_servers}" {"resource": "https://gmailmcp.googleapis.com/mcp/v1", "authorization_servers": ["https://accounts.google.com/"]} That server publishes two metadata documents, and both leave offline_access out: curl -s https://accounts.google.com/.well-known/openid-configuration | jq .scopes_supported curl -s https://accounts.google.com/.well-known/oauth-authorization-server | jq .scopes_supported ["openid", "email", "profile"] null The refresh grant itself is supported: curl -s https://accounts.google.com/.well-known/oauth-authorization-server | jq .grant_types_supported ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:jwt-bearer"] So Google can issue refresh tokens, and Claude Code's rule from Step 3 never fires. Step 5 - Ask Google for offline_access Anyway scope_check.sh sends a sign-in request for each scope set and reports where Google redirects it, without signing in. A valid request goes to the sign-in page. The first scope set is the check that a valid request passes; the last uses a scope that exists nowhere: G=https://www.googleapis.com/auth ./scope_check.sh "$G/gmail.readonly" "$G/gmail.readonly offline_access" "$G/gmail.readonly bogus_scope_xyz" scope=https://www.googleapis.com/auth/gmail.readonly -> /v3/signin/identifier scope=https://www.googleapis.com/auth/gmail.readonly offline_access -> /signin/oauth/error invalid_scope - Some requested scopes were invalid. {valid=[https://www.googleapis.com/auth/gmail.readonly], invalid=[offline_access]} scope=https://www.googleapis.com/auth/gmail.readonly bogus_scope_xyz -> /signin/oauth/error invalid_scope - Some requested scopes were invalid. {valid=[https://www.googleapis.com/auth/gmail.readonly], invalid=[bogus_scope_xyz]} Google answers offline_access exactly as it answers a made-up scope. Its metadata in Step 4 is accurate: Google leaves offline_access out because it does not support it. Step 6 - Ask Google the Google Way Google documents its own parameter for this. The refresh token "is only present in this response if you set the access_type parameter to offline ": Using OAuth 2.0 for Web Server Applications | Google for Developers oauth_probe.py makes the same Gmail sign-in as Claude Code (same OAuth client, scopes, redirect URI and resource ) with extra parameters added. prompt=consent makes Google show the consent screen again, which is when it issues a refresh token: python3 oauth_probe.py signin gmail gmail access_type=offline prompt=consent { "step": "signin", "label": "gmail", "server": "gmail", "extra": { "access_type": "offline", "prompt": "consent" }, "granted_scope": [ "https://www.googleapis.com/auth/gmail.compose", "https://www.googleapis.com/auth/gmail.readonly" ], "expires_in": 3599, "refresh_token_issued": true, "at": "2026-10-06T09:24:55-0400" } Trading that refresh token for a new access token, with no browser, returned a token Google's token-info endpoint accepted, with the full hour and both Gmail scopes: 2026-10-06T09:27:27-0400 refreshed expires_in=3599 One parameter is the whole difference between an hourly sign-in and one that renews. Step 7 - Script It Yourself with headersHelper Claude Code has a second way to authenticate an MCP server. headersHelper names a command whose output becomes the request headers: Connect Claude Code to tools via MCP | Claude Code Docs Per those docs, the command prints a JSON object such as {"Authorization": "Bearer …"} . Claude Code runs it at connect and on reconnect, runs it again and retries once when a tool call returns 401 or 403 , does not cache its output, and gives it 10 seconds. When the output carries an Authorization header, Claude Code skips its own OAuth for that server. So the refresh token from Step 6 can drive a server directly. token_header.py in the repository is the helper. It reads the server name from CLAUDE_CODE_MCP_SERVER_NAME and trades that server's refresh token for a new access token on every call. Claude Code calls it only when a server connects, so one call to Google's token endpoint per connect is the whole cost. Sign each server in once with offline access (oauth_probe.py knows gmail , drive and people ; add the others to its SERVERS table): export PROBE_DIR=~/.cache/oauth-probe python3 oauth_probe.py signin gmail gmail access_type=offline prompt=consent Then register the server with the helper in place of the oauth block: { "type": "http", "url": "https://gmailmcp.googleapis.com/mcp/v1", "headersHelper": "PROBE_DIR=~/.cache/oauth-probe python3 -I ~/workspace-mcp-claude/docs/article/token_header.py" } Run through Claude Code 2.1.292 with only that server configured, a request to list Gmail labels returned all 38, with the helper called once per session and no browser. Refreshing on every call matters. With a stored access token that Google no longer accepts but that still shows 50 minutes left, a helper that reused it was called once, the server still connected, and Gmail's tools were missing from the session: The tool is missing. ToolSearch returned: "No matching deferred tools found" Claude Code did not call the helper again when tools/list was refused. The same stale token with the always-refresh helper returned all 38 labels. The moving parts, all of them yours: - The OAuth client secret, read from ~/client_secret.txt on every refresh. - A sign-in script that adds access_type=offline andprompt=consent , run once per server. - A token file holding one refresh token per server, mode 600. - The helper, which must answer inside 10 seconds, including a call to Google's token endpoint. - One config entry per server with headersHelper and nooauth block. The tradeoffs: - A refresh token on disk works

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.