Skip to content

migrate-v1: remote MCP OAuth tokens (mcp-auth.json) are not migrated; every OAuth remote server needs re-auth after upgrade #54879

Open

Description

@juanmanueldaza

Description

Upgrading V1 (1.18.x) → V2 (2.0.27) does not carry over remote MCP OAuth credentials. Every OAuth-protected remote MCP server shows needs authentication after the upgrade, even though the V1 token store holds valid refresh tokens (offline_access).

The V1→V2 migration guide lists three intentional breaking areas (plugins, server API, tui.json→cli.json) and states other supported V1 behavior should keep working, so this appears to be a migration gap — or at minimum should be documented as a required re-auth step.

Environment

  • opencode v2.0.27 (upgraded from v1 1.18.x), Linux x86_64
  • Remote servers: Cloudflare managed MCPs (mcp.cloudflare.com, bindings/builds/observability.mcp.cloudflare.com), configured V1-style with "oauth": {}, authenticated and working throughout V1

Evidence

  1. V1 store ~/.local/share/opencode/mcp-auth.json contains access + refresh tokens for all 4 servers, last refreshed by the final V1 run immediately before the upgrade.
  2. V2 database: credential table holds only the 4 provider API keys imported by the import_legacy_credentials migration (openrouter/nvidia/moonshotai/opencode-go); kv holds only the migration.v1-v2 marker. No MCP OAuth rows anywhere.
  3. Server log at V2 startup, per Cloudflare server: mcp http authentication rejected status=401 → MCP server "..." requires a login before it can register a client — V2 attempts fresh dynamic client registration with no stored credential. Zero operation=refresh attempts, i.e. mcp-auth.json is never read.

Expected

Either import V1 mcp-auth.json credentials during the V1→V2 migration (refresh tokens are valid), or document in the migration guide that remote MCP OAuth requires re-authentication (opencode mcp auth <server>) after upgrade.

Workaround

opencode mcp auth <server> for each OAuth remote server after upgrading.

Related

Activity

  1. opencode-agent commented on Oct 12, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on v2.0.27, and it still happens on the latest dev (7bac892c).

    What I did:

    1. Ran opencode-ai 1.19.0 once to create a real v1 data directory.
    2. Added a v1-style auth.json (an API key) and a mcp-auth.json entry for a remote OAuth MCP server. The entry had a valid access token, a refresh token, clientInfo and serverUrl.
    3. Configured that server with "oauth": {}, pointing at a local mock OAuth MCP server that logs every request.
    4. Started v2 and ran opencode mcp list.

    Result: the v1 API key was imported into the credential table, but the MCP token was not. The server showed needs authentication. The mock only received unauthenticated POST /mcp requests plus OAuth discovery, with no Authorization header and no call to the token endpoint, so mcp-auth.json is never read. The script below reproduces it.

    This is the same problem as #53607, which was also reproduced but then closed automatically because of the issue template. For now, signing in again with opencode mcp auth <server> is the workaround, as you found.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #54879: V1 mcp-auth.json OAuth tokens are not imported by V2.
    # Requirements: bun, sqlite via bun:sqlite, no other opencode background service running, port 48765 free.
    # Steps: create a real V1 data dir with opencode-ai@1, add V1-style auth.json + mcp-auth.json, start V2, run `mcp list`,
    # then inspect the credential table and the requests the mock OAuth MCP server received.
    # Usage: ./repro.sh            (uses `opencode` from PATH)
    #        OC_DEV=/workspace/repo ./repro.sh   (runs from a source checkout)
    set -u
    ROOT=$(mktemp -d /tmp/opencode/mcp-mig.XXXX)
    BUN=$(command -v bun)
    # v1 (opencode-ai 1.x) is used once to create a genuine V1 data directory/database
    V1=${V1_BIN:-/tmp/opencode/v1/node_modules/.bin/opencode}
    [ -x "$V1" ] || { mkdir -p /tmp/opencode/v1 && (cd /tmp/opencode/v1 && bun add opencode-ai@1 >/dev/null); }
    PORT=48765
    LOG=$ROOT/mcp-requests.log
    
    # Mock remote MCP server with OAuth: logs every request (path + Authorization header),
    # accepts only "Bearer v1-access-token" or a token refreshed from "v1-refresh-token".
    cat > $ROOT/server.ts <<EOF
    const base = "http://127.0.0.1:$PORT"
    Bun.serve({ port: $PORT, async fetch(req) {
      const u = new URL(req.url); const auth = req.headers.get("authorization") ?? "-"
      const body = req.method === "POST" ? await req.text() : ""
      await Bun.write(Bun.file("$LOG"), (await Bun.file("$LOG").text().catch(() => "")) + req.method + " " + u.pathname + " auth=" + auth + (u.pathname === "/token" ? " body=" + body : "") + "\n")
      if (u.pathname === "/.well-known/oauth-protected-resource" || u.pathname === "/.well-known/oauth-protected-resource/mcp")
        return Response.json({ resource: base + "/mcp", authorization_servers: [base] })
      if (u.pathname.startsWith("/.well-known/oauth-authorization-server") || u.pathname.startsWith("/.well-known/openid-configuration"))
        return Response.json({ issuer: base, authorization_endpoint: base + "/authorize", token_endpoint: base + "/token", registration_endpoint: base + "/register", response_types_supported: ["code"], grant_types_supported: ["authorization_code","refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"] })
      if (u.pathname === "/token") return Response.json({ access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600, refresh_token: "v1-refresh-token" })
      if (u.pathname === "/register") return Response.json({ client_id: "new-client", redirect_uris: ["http://127.0.0.1:19876/mcp/oauth/callback"] }, { status: 201 })
      if (u.pathname === "/mcp") {
        if (auth !== "Bearer v1-access-token" && auth !== "Bearer refreshed-token")
          return new Response("unauthorized", { status: 401, headers: { "WWW-Authenticate": 'Bearer resource_metadata="' + base + '/.well-known/oauth-protected-resource/mcp"' } })
        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: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "mock", version: "1" } } : msg.method === "tools/list" ? { tools: [] } : {}
        return Response.json({ jsonrpc: "2.0", id: msg.id, result })
      }
      return new Response("nf", { status: 404 })
    }})
    EOF
    $BUN $ROOT/server.ts > $ROOT/server.out 2>&1 & SRV=$!
    sleep 1
    export HOME=$ROOT/home XDG_DATA_HOME=$ROOT/data XDG_CONFIG_HOME=$ROOT/config XDG_STATE_HOME=$ROOT/state XDG_CACHE_HOME=$ROOT/cache
    mkdir -p $XDG_DATA_HOME/opencode $ROOT/project $HOME
    echo "== v1: $($V1 --version)"; (cd $ROOT/project && timeout 60 $V1 mcp list >/dev/null 2>&1); ls $XDG_DATA_HOME/opencode
    
    # V1-style credential stores, as left behind by opencode 1.18.x
    NOW=$(date +%s)
    cat > $XDG_DATA_HOME/opencode/auth.json <<EOF
    { "openrouter": { "type": "api", "key": "sk-or-test" } }
    EOF
    cat > $XDG_DATA_HOME/opencode/mcp-auth.json <<EOF
    { "cf": { "tokens": { "accessToken": "v1-access-token", "refreshToken": "v1-refresh-token", "expiresAt": $((NOW+3600)), "scope": "offline_access" },
              "clientInfo": { "clientId": "v1-client", "clientIdIssuedAt": $NOW },
              "serverUrl": "http://127.0.0.1:$PORT/mcp" } }
    EOF
    cat > $ROOT/project/opencode.json <<EOF
    { "\$schema": "https://opencode.ai/config.json", "mcp": { "cf": { "type": "remote", "url": "http://127.0.0.1:$PORT/mcp", "oauth": {} } } }
    EOF
    
    mkdir -p $XDG_CONFIG_HOME/opencode && cp $ROOT/project/opencode.json $XDG_CONFIG_HOME/opencode/opencode.json
    cd $ROOT/project
    if [ -n "${OC_DEV:-}" ]; then
      # run from packages/cli so its bunfig.toml (solid preload) also applies to the spawned server process
      cd $OC_DEV/packages/cli; export OPENCODE_DISABLE_CHANNEL_DB=1  # use opencode.db like a release build
      OC=(bun --conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js $OC_DEV/packages/cli/src/index.ts)
    else OC=(opencode); fi
    echo "== version: $("${OC[@]}" --version 2>&1 | tail -1)"
    echo "== opencode mcp list"
    timeout 60 "${OC[@]}" mcp list 2>&1 | tail -15; sleep 3; timeout 60 "${OC[@]}" mcp list 2>&1 | tail -5
    echo "== credential table"
    DB=$(ls $XDG_DATA_HOME/opencode/*.db 2>/dev/null | head -1)
    $BUN -e "const {Database}=require('bun:sqlite');const d=new Database('$DB');console.log(d.query('select integration_id, substr(value,1,80) v from credential').all())" 2>&1
    echo "== requests seen by the mock MCP server"
    cat $LOG; cat $ROOT/server.out
    echo '== mcp log lines'; grep -h 'mcp\\|migrat' $XDG_DATA_HOME/opencode/log/*.log | grep -v watcher | grep -o 'message="[^"]*"[^ ]*\\( status[^ ]*\\)\\?' | sort | uniq -c | head -20
    kill $SRV

    Run it: bash repro.sh

  2. argszero commented on Oct 13, 2026

    @argszero
    Contributor

    I'd like to work on this.

    I verified the gap on dev (d78267503d) rather than taking the diagnosis on trust:

    • packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts imports <data>/auth.json into the credential table. Nothing in the tree reads mcp-auth.json — a code search for that name comes back with only the docs (packages/web/src/content/docs/**/mcp-servers.mdx, which still describes mcp-auth.json as the token store).

    • The destination already exists and is deterministic. Every remote MCP server without oauth: false is registered as an OAuth integration whose id is built in packages/core/src/mcp/index.ts:

      "mcp_" + sha1(name + <NUL> + url).slice(0, 16)

      and McpOAuth.connectProvider reads the credential back with Credential.Service.list(integrationID) (packages/core/src/mcp/oauth.ts). A missing row is exactly what produces the unauthenticated POST /mcp you observed.

    Plan: add a sibling migration beside import_legacy_credentials that reads <data>/mcp-auth.json and, for each entry, recomputes that integration id from the entry's server name and serverUrl and writes a Credential.OAuth row in the same shape McpOAuth.toCredential produces (access/refresh/expiry plus the client information and serverUrl in metadata). It keeps the existing "skip anything already present" guard, so it stays a no-op on a store that has already signed in again.

    Coverage: a test that seeds a V1-shaped mcp-auth.json in a temporary data directory and asserts the imported credential, alongside packages/core/test/database-migration.test.ts, plus a case pinning that a pre-existing credential is left untouched.

    I'll confirm the exact V1 entry shape from the V1 source before writing the decoder, and share the diff once the tests pass.

  3. jethac commented on Oct 14, 2026

    @jethac

    Same on 2.0.28 (macOS) with Google's hosted Workspace MCP servers (gmailmcp, drivemcp, calendarmcp.googleapis.com). One extra symptom makes this harder to spot than with servers that 401 on connect:

    Google's MCP endpoints accept initialize and tools/list without a token and return 401 only on tools/call. So after the upgrade, opencode mcp list shows all three as ✓ connected and the tools appear, but every tool call fails with MCP server "…" requires user authorization. There is no needs authentication status to point at re-auth. The V1 tokens in mcp-auth.json were valid; I confirmed this by calling the endpoints with them directly.

    Workaround is the same: opencode mcp auth <server> for each one. Worth covering servers like these in the migration or the docs, because mcp list makes them look healthy.

  4. okcompute commented on Oct 14, 2026

    @okcompute

    Confirming this gap from a macOS V2 install, with two extra symptoms that follow from it.

    Environment: opencode v2.0.29, macOS 27.0.1 (arm64), install via npm global under fnm.

    The V1 leftover in ~/.local/share/opencode/mcp-auth.json looks like this:

    "my-mcp-server": {
      "clientInfo":   { "clientId": "...", "clientSecret": "..." },
      "tokens":       { "accessToken": "...", "expiresAt": 0, "scope": "..." },
      "oauthState":   "...",
      "codeVerifier": "...",
      "serverUrl":    "https://<internal-host>/api/mcp-actions/v1/portal"
    }

    Two additional symptoms, both silent, both fallout from the record being invisible to V2:

    1. opencode mcp logout my-mcp-server prints No stored credentials for my-mcp-server and exits 0 — while the entry above is still on disk, unchanged, same clientId. Logout presumably only consults the V2 credential store, so V1 records are neither visible nor removable through any supported command. A user cannot distinguish "no credentials" from "credentials V2 cannot see".

    2. Because those records are invisible, a stale dynamically-registered client_id in the V1 file persists indefinitely. I spent a while chasing exactly that as the cause of a failed OAuth sign-in; it turned out to have no effect on V2 at all, which was performing its own registration. The stale entry is dead weight that no command surfaces or clears.

    Not arguing that migration is the right fix here — asking users to re-auth on upgrade is a reasonable call. But logout reporting success while leaving the credentials on disk looks like a bug independent of whatever you decide about migration, so it may be worth tracking even if the migration gap is closed as "won't fix, re-auth required". Happy to file that separately if you would rather keep this issue scoped to the migration itself.

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