Skip to content

mcp: OAuth authorization never requests offline access, so Google issues no refresh token #59820

Open

Description

@aNewMoose

[ai-assisted]

Description

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:

"mcp oauth authorization completed" server=gmail hasRefreshToken=false expiresIn=3599

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

  1. Configure a remote MCP server with oauth (e.g. Google's Gmail MCP server) in opencode.json.
  2. Run opencode mcp auth <name> and complete sign-in.
  3. Check the log: mcp oauth authorization completed ... hasRefreshToken=false expiresIn=3599.
  4. Wait about an hour (or clear the stored credential's access token): every call 401s and the server reports requires user authorization.

Operating System

macOS 26.6

Terminal

tmux

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr

    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")
    TS
    echo '{ "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=""
    for i in $(seq 1 30); do URL=$(grep -o 'http://127.0.0.1:8789/authorize[^ ]*' "$W/auth.out" | head -1); [ -n "$URL" ] && break; sleep 1; done
    echo "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 $MOCK 2>/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

    Run it: sh repro.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions