You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
mcp: OAuth authorization never requests offline access, so Google issues no refresh token #59820
Sign-in to Google's Gmail MCP server (https://gmailmcp.googleapis.com/mcp/v1) succeeds, but about an hour later every request 401s and the log reports MCP server "https://gmailmcp.googleapis.com/mcp/v1" requires user authorization, repeating hourly. The token exchange succeeded but returned no refresh token:
Google only returns a refresh token when the authorization request contains access_type=offline ("the refresh token is only returned if your application set the access_type parameter to offline in the initial request to Google's authorization server"). The authorization URL OpenCode builds never sets it (redirectToAuthorization in packages/core/src/mcp/oauth.ts), so the ~1-hour access token just expires and there is no refresh token to renew it. Other servers in the same log (Linear, Sentry) show hasRefreshToken=true and refresh fine, so the refresh path itself works.
Workaround: opencode mcp auth <name> re-authorizes, but has to be repeated about hourly.
Suggested fix: set access_type=offline on the authorization URL at the redirect funnel. RFC 6749 §3.1 requires authorization servers to ignore unrecognized request parameters, so other providers are unaffected. Happy to send a PR.
Plugins
gmail MCP (remote server https://gmailmcp.googleapis.com/mcp/v1), no other plugins involved
OpenCode version
v2.2.0
Steps to reproduce
Configure a remote MCP server with oauth (e.g. Google's Gmail MCP server) in opencode.json.
Run opencode mcp auth <name> and complete sign-in.
Check the log: mcp oauth authorization completed ... hasRefreshToken=false expiresIn=3599.
Wait about an hour (or clear the stored credential's access token): every call 401s and the server reports requires user authorization.
Thanks for the detailed report. I reproduced this on 2.2.0, and it still happens on the latest dev (86ce19d).
The script below starts a local mock OAuth-protected MCP server and runs opencode mcp auth gmail against it. Like Google without offline access, the mock's token endpoint returns no refresh token. Sign-in completes ("Connected to gmail"), but the authorization URL OpenCode opens contains only response_type, client_id, code_challenge, code_challenge_method, redirect_uri, state and resource. There is no access_type=offline, so Google won't issue a refresh token. On dev I confirmed the same with a unit test around McpOAuth.authorize: access_type is null.
I couldn't test against Google's real server without an account, so the "no refresh token" part relies on Google's documented behaviour, which matches your hasRefreshToken=false log.
It may also be relevant that #46361 / #50519 deliberately removed a forced prompt=consent. Accounts that already granted consent may need a fresh consent before Google returns a refresh token, even after this change.
Reproduction script (repro.sh)
#!/bin/sh# Reproduces #59820: the MCP OAuth authorization URL opencode builds has no access_type=offline,# so Google issues no refresh token. Starts a mock OAuth-protected MCP server (no real Google account needed),# runs `opencode mcp auth gmail`, completes the login with curl and prints the authorization URL.# Set OPENCODE to override the command (default: opencode from PATH, e.g. @opencode/cli@2.2.0).set -u
OPENCODE=${OPENCODE:-opencode}
W=$(mktemp -d); mkdir -p "$W/proj""$W/home"
cat >"$W/mock.ts"<<'TS'// Mock OAuth-protected MCP server. Logs the authorization URL OpenCode opens,// and (like Google without access_type=offline) issues no refresh_token.const P = 8789, B = `http://127.0.0.1:${P}`Bun.serve({ port: P, async fetch(req) { const u = new URL(req.url) if (u.pathname.startsWith("/.well-known/oauth-protected-resource")) return Response.json({ resource: `${B}/mcp`, authorization_servers: [B] }) if (u.pathname.startsWith("/.well-known/oauth-authorization-server") || u.pathname.startsWith("/.well-known/openid-configuration")) return Response.json({ issuer: B, authorization_endpoint: `${B}/authorize`, token_endpoint: `${B}/token`, registration_endpoint: `${B}/register`, response_types_supported: ["code"], grant_types_supported: ["authorization_code","refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["https://www.googleapis.com/auth/gmail.readonly"] }) if (u.pathname === "/register") { const b = await req.json(); return Response.json({ ...b, client_id: "cid" }, { status: 201 }) } if (u.pathname === "/authorize") { console.log("AUTHORIZE_URL", u.toString()) console.log("access_type =", u.searchParams.get("access_type")) const r = new URL(u.searchParams.get("redirect_uri")!); r.searchParams.set("code", "c"); if (u.searchParams.get("state")) r.searchParams.set("state", u.searchParams.get("state")!) return Response.redirect(r.toString(), 302) } if (u.pathname === "/token") { const f = await req.formData(); console.log("TOKEN", f.get("grant_type")) return Response.json({ access_token: "at", token_type: "Bearer", expires_in: 3599 }) } if (u.pathname === "/mcp") { if (req.headers.get("authorization") !== "Bearer at") return new Response("unauth", { status: 401, headers: { "WWW-Authenticate": `Bearer resource_metadata="${B}/.well-known/oauth-protected-resource/mcp"` } }) const m = await req.json().catch(() => ({})) if (m.method === "initialize") return Response.json({ jsonrpc: "2.0", id: m.id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "mock", version: "1" } } }) if (m.id === undefined) return new Response(null, { status: 202 }) return Response.json({ jsonrpc: "2.0", id: m.id, result: { tools: [] } }) } return new Response("nf", { status: 404 })}})console.log("mock up")TSecho'{ "mcp": { "gmail": { "type": "remote", "url": "http://127.0.0.1:8789/mcp", "oauth": {} } } }'>"$W/proj/opencode.json"
bun "$W/mock.ts">"$W/mock.log"2>&1& MOCK=$!
sleep 1
cd"$W/proj"
HOME="$W/home" XDG_DATA_HOME="$W/home/data" timeout 60 $OPENCODE mcp auth gmail </dev/null >"$W/auth.out"2>&1&
URL=""foriin$(seq 1 30);do URL=$(grep -o 'http://127.0.0.1:8789/authorize[^ ]*'"$W/auth.out"| head -1); [ -n"$URL" ] &&break; sleep 1;doneecho"Authorization URL: $URL"
[ -n"$URL" ] && curl -sL "$URL">/dev/null
wait %2 2>/dev/null; sleep 2
tail -4 "$W/auth.out"; cat "$W/mock.log"kill$MOCK2>/dev/null; pkill -f "opencode.*serve --service"2>/dev/null
case"$URL"in*access_type=offline*) echo"RESULT: access_type=offline present";; *) echo"RESULT: access_type=offline MISSING (bug)";; esac
Activity
opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I reproduced this on 2.2.0, and it still happens on the latest
dev(86ce19d).The script below starts a local mock OAuth-protected MCP server and runs
opencode mcp auth gmailagainst it. Like Google without offline access, the mock's token endpoint returns no refresh token. Sign-in completes ("Connected to gmail"), but the authorization URL OpenCode opens contains onlyresponse_type,client_id,code_challenge,code_challenge_method,redirect_uri,stateandresource. There is noaccess_type=offline, so Google won't issue a refresh token. OndevI confirmed the same with a unit test aroundMcpOAuth.authorize:access_typeisnull.I couldn't test against Google's real server without an account, so the "no refresh token" part relies on Google's documented behaviour, which matches your
hasRefreshToken=falselog.It may also be relevant that #46361 / #50519 deliberately removed a forced
prompt=consent. Accounts that already granted consent may need a fresh consent before Google returns a refresh token, even after this change.Reproduction script (repro.sh)
Run it:
sh repro.sh