Skip to content

mcp: expired OAuth bearer on reconnect hits 429 instead of 401, so refresh never runs #60269

Open

Description

@sk-connectpoint

Description

On OpenCode v2.2.0, a remote OAuth MCP can stay failed for hours because reconnect sends an expired access token, the server answers HTTP 429 (Retry later) instead of 401, and OpenCode only refreshes after 401. No operation=refresh runs even though a valid refresh token is stored in the V2 credential table.

Follow-up requested by the closer of #49773 (closed as not planned / misclassified as v1). This is a v2 report.

Expected: when the stored access token is expired/near expiry and a refresh token exists, OpenCode refreshes before (or without depending on) the first MCP POST, then connects with a fresh bearer. A connect-time 429 should back off (Retry-After if present) instead of retrying every ~hour with the same expired bearer.

Actual:

  1. Token lifetime ~2h (expiresIn=7200). Refresh token remains valid.
  2. After expiry, reconnect POSTs /api/v4/mcp with the expired Bearer.
  3. When the server returns 401: operation=refresh 200 → mcp connected with tools.
  4. When the server returns 429 with body Retry later: mcp connect failed / Error POSTing to endpoint: Retry later (HTTP 429) and no refresh attempt.
  5. ~Hourly reconnects repeat the same expired Bearer while 429 persists. The same 429 was also seen on POST /oauth/token (exchange) and OAuth discovery GETs during opencode mcp auth.

Notes:

MCP: remote GitLab instance MCP, type: remote, URL https://<host>/api/v4/mcp. OAuth: static client in config (clientId, redirectUri, scope=mcp); credentials in ~/.local/share/opencode/opencode.db credential table (not v1 mcp-auth.json).

Plugins

unrelated local hooks only (litellm-session-id, mistral-cache-key, strip-reasoning-content, caveman-*, rtk-rewrite)

OpenCode version

2.2.0 (@opencode/cli)

Steps to reproduce

  1. Configure a remote OAuth MCP (GitLab instance MCP is one example) and complete opencode mcp auth <server>.
  2. Wait until the access token expires (~2h) while keeping the refresh token.
  3. Reconnect (opencode mcp list or open a session).
  4. If the MCP POST returns 429 (Retry later) instead of 401, note that the log shows no mcp oauth request … operation=refresh.
  5. When the same setup later gets 401 on that first POST, refresh runs and the server connects.

Operating System

Linux (WSL2)

Terminal

bash

Activity

  1. opencodejr commented on Oct 29, 2026

    @opencodejr

    Thanks for the detailed report, @sk-connectpoint. I can reproduce this on 2.2.0, and it still happens on the latest dev (87a35fa).

    The script below runs a local mock OAuth MCP server. Its access tokens last 3 seconds, and once a token has expired it answers with HTTP 429 Retry later instead of 401. The script then:

    1. Runs opencode mcp auth gl. The first connection works.
    2. Stops the background service and waits for the token to expire.
    3. Runs opencode mcp list.

    Result: ✗ gl failed: Error POSTing to endpoint: Retry later (HTTP 429). OpenCode sends initialize three times with the expired bearer, and the token endpoint never gets a refresh_token request, even though the stored credential has an expiry time and a refresh token. When the mock answers the expired bearer with 401 instead, OpenCode refreshes right away and connects.

    So on this path, refresh only runs after a 401. Nothing refreshes an expired token before the first request. I did not test the Retry-After back-off part of your report; that overlaps with #52854 (PR #52855). Related: #49773 and #34582 (same missing refresh before connecting), and PR #53275 (a 429 during the refresh itself).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #60269: expired MCP OAuth access token + server answering 429 => no refresh attempted.
    # Usage: bash repro.sh            (uses `opencode` from PATH)
    #        OC_DEV=/workspace/repo bash repro.sh   (runs the dev checkout from source)
    set -u
    HERE=$(cd "$(dirname "$0")" && pwd)
    W=$(mktemp -d /tmp/opencode/repro60269.XXXX)
    export HOME=$W/home XDG_DATA_HOME=$W/data XDG_CONFIG_HOME=$W/config XDG_STATE_HOME=$W/state XDG_CACHE_HOME=$W/cache
    mkdir -p $HOME $W/proj
    PORT=${PORT:-8787}
    cat > $W/mock.ts <<'MOCK'
    // Mock remote OAuth MCP server: an expired bearer gets HTTP 429 "Retry later" (as a rate-limited
    // GitLab does) instead of 401. Logs every request so we can see whether a refresh_token grant happens.
    const PORT = Number(process.env.PORT ?? 8787)
    const origin = `http://127.0.0.1:${PORT}`
    const ACCESS_TTL = 3 // seconds
    const issued = new Map<string, number>() // access token -> expiry ms
    let n = 0
    const log = (...a: unknown[]) => console.log(new Date().toISOString(), ...a)
    
    Bun.serve({
      port: PORT,
      hostname: "127.0.0.1",
      async fetch(req) {
        const url = new URL(req.url)
        const auth = req.headers.get("authorization") ?? ""
        const body = req.method === "POST" ? await req.text() : ""
        log(req.method, url.pathname, auth ? `auth=${auth.slice(0, 20)}` : "no-auth", body.slice(0, 120))
        if (url.pathname.startsWith("/.well-known/oauth-protected-resource"))
          return Response.json({ resource: `${origin}/mcp`, authorization_servers: [origin] })
        if (url.pathname.startsWith("/.well-known/oauth-authorization-server") || url.pathname.startsWith("/.well-known/openid-configuration"))
          return Response.json({
            issuer: origin,
            authorization_endpoint: `${origin}/authorize`,
            token_endpoint: `${origin}/token`,
            registration_endpoint: `${origin}/register`,
            response_types_supported: ["code"],
            grant_types_supported: ["authorization_code", "refresh_token"],
            code_challenge_methods_supported: ["S256"],
            token_endpoint_auth_methods_supported: ["none"],
          })
        if (url.pathname === "/register") {
          const meta = JSON.parse(body)
          return Response.json({ ...meta, client_id: "client-1", token_endpoint_auth_method: "none" }, { status: 201 })
        }
        if (url.pathname === "/authorize") {
          const to = new URL(url.searchParams.get("redirect_uri")!)
          to.searchParams.set("code", "code-1")
          to.searchParams.set("state", url.searchParams.get("state") ?? "")
          return Response.redirect(to.href, 302)
        }
        if (url.pathname === "/token") {
          const p = new URLSearchParams(body)
          const grant = p.get("grant_type")
          log(`TOKEN grant_type=${grant}`)
          const access = `access-${++n}`
          issued.set(access, Date.now() + ACCESS_TTL * 1000)
          return Response.json({ access_token: access, token_type: "Bearer", refresh_token: `refresh-${n}`, expires_in: ACCESS_TTL, scope: "mcp" })
        }
        if (url.pathname === "/mcp") {
          if (!auth.startsWith("Bearer "))
            return new Response("unauthorized", {
              status: 401,
              headers: { "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"` },
            })
          const exp = issued.get(auth.slice(7))
          if (!exp || exp < Date.now()) {
            log(`MCP expired bearer -> ${process.env.EXPIRED_STATUS ?? 429}`)
            return Number(process.env.EXPIRED_STATUS ?? 429) === 401 ? new Response("expired", { status: 401, headers: { "WWW-Authenticate": `Bearer error="invalid_token", resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"` } }) : new Response("Retry later", { status: 429 })
          }
          if (req.method !== "POST") return new Response(null, { status: 405 })
          const msg = JSON.parse(body)
          if (!("id" in msg)) return new Response(null, { status: 202 })
          const result =
            msg.method === "initialize"
              ? { protocolVersion: msg.params?.protocolVersion ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "mock", version: "1" } }
              : msg.method === "tools/list"
                ? { tools: [{ name: "ping", description: "ping", inputSchema: { type: "object" } }] }
                : {}
          return Response.json({ jsonrpc: "2.0", id: msg.id, result })
        }
        return new Response("not found", { status: 404 })
      },
    })
    log("mock listening", origin)
    MOCK
    oc() {
      if [ -n "${OC_DEV:-}" ]; then
        (cd $W/proj && bun --conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js $OC_DEV/packages/cli/src/index.ts "$@")
      else
        (cd $W/proj && opencode "$@")
      fi
    }
    cat > $W/proj/opencode.json <<EOF
    { "mcp": { "servers": { "gl": { "type": "remote", "url": "http://127.0.0.1:$PORT/mcp" } } } }
    EOF
    PORT=$PORT bun "$W/mock.ts" > $W/mock.log 2>&1 &
    MOCK=$!
    sleep 1
    oc --version
    # From source the CLI cannot spawn its background service with the preload, so start it ourselves.
    svc() { if [ -n "${OC_DEV:-}" ]; then oc serve --service > $W/svc.log 2>&1 & sleep 6; fi; }
    svc
    echo "== opencode mcp auth gl (completing the authorize redirect with curl)"
    oc mcp auth gl > $W/auth.log 2>&1 < /dev/null &
    AUTH=$!
    for i in $(seq 1 30); do
      URL=$(grep -o "http://127.0.0.1:$PORT/authorize[^ ]*" $W/auth.log | head -1)
      [ -n "$URL" ] && break; sleep 1
    done
    curl -s -L -o /dev/null "$URL"
    wait $AUTH; cat $W/auth.log | tail -3
    echo "== stopping the background service so the next command reconnects from the stored credential"
    pkill -f "[s]erve --service"; sleep 1
    svc
    echo "== waiting for the 3s access token to expire"
    sleep 6
    echo "== opencode mcp list"
    oc mcp list 2>&1 | tail -5; sleep 3; echo "== opencode mcp list (again)"; oc mcp list 2>&1 | tail -5
    sleep 2
    echo "== mock server log"
    cat $W/mock.log
    echo "== token grants seen:"; grep TOKEN $W/mock.log
    kill $MOCK 2>/dev/null
    pkill -f "[s]erve --service" 2>/dev/null

    Run it: bash repro.sh

  2. Vaili commented on Oct 31, 2026

    @Vaili

    I reproduced the missing refresh on dev and I'd like to take it. I'll start with a test for an expired stored MCP credential, then refresh before the first request instead of waiting for a 401. Retry-After back-off stays with #52855.

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