Skip to content

MCP: stored OAuth token is never refreshed when the server reports expiry in a 200 tool result #62199

Description

@aNewMoose

[ai-assisted]

Description

On OpenCode v2.2.5, a remote MCP server that uses OAuth is never refreshed when its access token expires if the server reports the expiry inside a successful HTTP 200 JSON-RPC tool result instead of an HTTP 401.

LaunchDarkly's hosted MCP is one such server. After the ~2h access token expires, a tool call returns isError: true with {"code":"token_expired","status":401,"action":"reauthenticate"} inside a 200 response. OpenCode's MCP client refreshes only after a transport 401, so the stored refresh token is never used. The server still connects and lists tools, so opencode mcp list shows it as connected. The only workaround is opencode mcp auth <server> or reconnecting through /mcp, and the problem returns at the next expiry.

Related: #49773 (closed not planned; the 2026-10-06 LaunchDarkly comment reports this behavior) and #34582 (V2 refresh on a transport 401).

Plugins

None.

OpenCode version

v2.2.5 (Homebrew opencode-v2)

Steps to reproduce

  1. Configure a remote MCP OAuth server whose tool calls return a 200 tool result carrying an expired-token error (LaunchDarkly https://mcp.launchdarkly.com/mcp is an example).
  2. Complete opencode mcp auth <server>; the credential stores a refresh token and a ~7200s access token.
  3. Let the access token expire while the session stays open.
  4. Call a tool. It fails with the server's reauthenticate error; there is no operation=refresh for that server in the log. opencode mcp list still reports the server as connected.
  5. Run opencode mcp auth <server>; calls work again until the next expiry.

Screenshot

Not applicable.

Operating System

macOS 26.6 (arm64)

Terminal

OpenCode CLI/TUI

Activity

  1. opencodejr commented on Nov 5, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I reproduced this on v2.2.5 with a local mock MCP/OAuth server built to act like the servers you describe.

    The mock issues 15-second access tokens plus a refresh token. Once a token expires, it still accepts initialize/tools/list, but tools/call returns an HTTP 200 result with isError: true and {"code":"token_expired","status":401,"action":"reauthenticate"}.

    Steps:

    1. Run opencode mcp auth mock. The token exchange succeeds and opencode mcp list shows the server as connected.
    2. Wait past the token's expiry.
    3. Start OpenCode again against the same database.

    What I saw: OpenCode reconnects using the stored, already expired access token, the server still counts as connected, and no refresh_token request ever reaches the token endpoint. The only token requests were the initial authorization-code exchanges. So the stored expiry and refresh token go unused unless the server returns a real 401. I ran out of time before capturing the failing tool call itself in the TUI.

    I haven't run the latest dev (e3b0ca6). From reading the code, refresh is still triggered only by a 401 there. The script below is the TUI part of the reproduction and needs the mock server running.

    Related: #49773 (closed, but its later comments describe the same LaunchDarkly/Homey behaviour on v2), #60269 (same missing refresh when the server answers 429 instead of 401), and #60612 (concurrent refreshes of rotating tokens).

    Reproduction script (drive.ts)
    // Repro for #62199. Prereqs: `bun mock.ts` running (mock MCP+OAuth, 15s token TTL, expiry reported as HTTP 200 isError),
    // and `opencode mcp auth mock` done against OPENCODE_DB=/workspace/repro/db.sqlite. Run with OPENCODE_DRIVE_DB=/workspace/repro/db.sqlite.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        config: { mcp: { mock: { type: "remote", url: "http://127.0.0.1:8787/mcp", codemode: false } }, permission: "allow" },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          // Each user turn: first request -> call the MCP tool, second request -> a text marker.
          let n = 0
          yield* llm.serve((request) => {
            const raw = JSON.stringify(request)
            if (raw.includes("You name conversations")) return Stream.make(Llm.text("MCP refresh repro"))
            const index = n++
            const tool = raw.match(/"(mock[_.]ping)"/)?.[1] ?? "mock_ping"
            console.log("REQUEST", index, "tool name in request:", tool)
            return index % 2 === 0
              ? Stream.make(Llm.toolCall({ index: 0, id: `call_${index}`, name: tool, input: {} }), Llm.finish("tool-calls"))
              : Stream.make(Llm.text(`TURN ${(index + 1) / 2} DONE`))
          })
          yield* ui.submit("call mock ping")
          yield* ui.waitFor("TURN 1 DONE", { timeout: 60000 })
          yield* ui.screenshot("first-call")
          // wait past the mock's 15s access-token lifetime
          yield* Effect.sleep("25 seconds")
          yield* ui.submit("call mock ping again")
          yield* ui.waitFor("TURN 2 DONE", { timeout: 60000 })
          yield* ui.screenshot("second-call-token-expired")
          yield* Effect.sleep("2 seconds")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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