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
migrate-v1: remote MCP OAuth tokens (mcp-auth.json) are not migrated; every OAuth remote server needs re-auth after upgrade #54879
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
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.
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.
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.
Thanks for the detailed report. I reproduced this on v2.0.27, and it still happens on the latest dev (7bac892c).
What I did:
Ran opencode-ai 1.19.0 once to create a real v1 data directory.
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.
Configured that server with "oauth": {}, pointing at a local mock OAuth MCP server that logs every request.
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 <<EOFconst 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 $HOMEecho"== 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 processcd$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);fiecho"== 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>&1echo"== 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
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.
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.
Two additional symptoms, both silent, both fallout from the record being invisible to V2:
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".
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.
Activity
opencode-agent commentedon Oct 12, 2026
Thanks for the detailed report. I reproduced this on v2.0.27, and it still happens on the latest
dev(7bac892c).What I did:
auth.json(an API key) and amcp-auth.jsonentry for a remote OAuth MCP server. The entry had a valid access token, a refresh token,clientInfoandserverUrl."oauth": {}, pointing at a local mock OAuth MCP server that logs every request.opencode mcp list.Result: the v1 API key was imported into the
credentialtable, but the MCP token was not. The server showedneeds authentication. The mock only received unauthenticatedPOST /mcprequests plus OAuth discovery, with no Authorization header and no call to the token endpoint, somcp-auth.jsonis 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)
Run it:
bash repro.shargszero commentedon Oct 13, 2026
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.tsimports<data>/auth.jsoninto thecredentialtable. Nothing in the tree readsmcp-auth.json— a code search for that name comes back with only the docs (packages/web/src/content/docs/**/mcp-servers.mdx, which still describesmcp-auth.jsonas the token store).The destination already exists and is deterministic. Every remote MCP server without
oauth: falseis registered as an OAuth integration whose id is built inpackages/core/src/mcp/index.ts:"mcp_" + sha1(name + <NUL> + url).slice(0, 16)and
McpOAuth.connectProviderreads the credential back withCredential.Service.list(integrationID)(packages/core/src/mcp/oauth.ts). A missing row is exactly what produces the unauthenticatedPOST /mcpyou observed.Plan: add a sibling migration beside
import_legacy_credentialsthat reads<data>/mcp-auth.jsonand, for each entry, recomputes that integration id from the entry's server name andserverUrland writes aCredential.OAuthrow in the same shapeMcpOAuth.toCredentialproduces (access/refresh/expiry plus theclientinformation andserverUrlin 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.jsonin a temporary data directory and asserts the imported credential, alongsidepackages/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.
jethac commentedon Oct 14, 2026
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
initializeandtools/listwithout a token and return 401 only ontools/call. So after the upgrade,opencode mcp listshows all three as✓ connectedand the tools appear, but every tool call fails withMCP server "…" requires user authorization. There is noneeds authenticationstatus to point at re-auth. The V1 tokens inmcp-auth.jsonwere 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, becausemcp listmakes them look healthy.okcompute commentedon Oct 14, 2026
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.jsonlooks like this:Two additional symptoms, both silent, both fallout from the record being invisible to V2:
opencode mcp logout my-mcp-serverprintsNo stored credentials for my-mcp-serverand exits 0 — while the entry above is still on disk, unchanged, sameclientId. Logout presumably only consults the V2credentialstore, so V1 records are neither visible nor removable through any supported command. A user cannot distinguish "no credentials" from "credentials V2 cannot see".Because those records are invisible, a stale dynamically-registered
client_idin 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
logoutreporting 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.