Skip to content

fix(core): import V1 MCP OAuth credentials during migration - #55197

Closed
argszero wants to merge 4 commits into
anomalyco:devfrom
argszero:mcp-oauth-migration
Closed

argszero wants to merge 4 commits into
anomalyco:devfrom
argszero:mcp-oauth-migration

Conversation

@argszero

@argszero argszero commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #54879

Type of change

  • Bug fix

What does this PR do?

V1 kept remote MCP OAuth tokens in mcp-auth.json under the global data directory. V2 never reads it, so after an upgrade the integration V2 derives for a remote server — mcp_<sha1(name + "\0" + url)> — has no credential row. connectProvider then finds nothing, falls back to an empty in-memory store, and every OAuth server reports needs_auth even though the V1 store still holds a valid refresh token.

This imports those entries in the migration that already imports the V1 provider credentials, keyed by the same integration the server registers under:

  • packages/core/src/mcp/integration-id.ts — the id derivation, moved out of the MCP layer into a leaf module: the import re-derives it and the layer calls the same function, so the two cannot drift apart.
  • packages/core/src/database/migration/20261013150000_import_legacy_mcp_credentials.ts — reads mcp-auth.json and writes one OAuth credential per usable entry: access/refresh, expires converted from V1's seconds to V2's milliseconds, the serverUrl/tokenType/scope metadata toTokens reads back, and the dynamically registered client the tokens were issued to — refreshing against a different client than the refresh token belongs to is rejected by the authorization server.
  • Entries without a serverUrl or without tokens are skipped: V1 only ever read an entry back through a matching URL, and a login in flight has no tokens yet. An integration that already holds a credential keeps it, mirroring the existing provider-credential import.

How did you verify your code works?

  • New tests in packages/core/test/database-migration.test.ts: a mcp-auth.json holding a full entry (client info, scope, abandoned verifier and state), an entry without client info, and two unusable entries. They assert the two rows the import writes (including the millisecond expiry and the exact integration ids), that a credential written by a later V2 login for the same server wins, and that the source file is left byte-identical. The stored row is then decoded through McpOAuth.toTokens / McpOAuth.clientFromCredential to pin exactly what the connect-time provider reads back.
  • bun test test/database-migration.test.ts: 21 pass, 0 fail. bun run typecheck in packages/core, bun script/migration.ts --check and oxlint on the changed files are clean.
  • bun test test/mcp.test.ts test/mcp-oauth.test.ts: 97 pass. Two failures (modern protocol > exposes every connection operation, discovers and reads MCP resources through Code Mode) reproduce with this branch's changes to mcp/index.ts set aside, so they are not from this PR.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #53607

The legacy credential import has to re-derive the integration a remote MCP
server registers under, so the derivation moves out of the layer into a leaf
module both can call.
Upgrading from V1 left every OAuth remote MCP server reporting needs
authentication: the V1 token store (`mcp-auth.json`) was never read, so the
integration V2 derives from the server's name and url had no credential row and
each connect started a fresh dynamic registration instead of refreshing the
tokens V1 held.

Import those entries once, keyed by the same integration the server registers
under, reusing the client registration the tokens were issued to.
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As submitted, this doesn't work: integration-id.ts imports from @opencode-ai/schema, which doesn't exist in this repo. The CLI server exits on startup with Cannot find package '@opencode-ai/schema', bun run typecheck in packages/core fails with TS2307, and test/database-migration.test.ts can't load.

I ran the reproduction script from #54879 against a real V1 1.19.0 data directory and a mock OAuth MCP server. On the base, the bug reproduces. With only that import changed to @opencode/schema/integration, the approach works: the V1 tokens are written under the right integration id, mcp list shows the server as connected, the mock receives Bearer v1-access-token, the typecheck is clean, and the migration tests pass (22/22). The rest of the design looks right: the id helper is shared with mcp/index.ts, the client registration is kept, and an existing V2 credential wins.

Public API: unchanged. The migration only reads mcp-auth.json and leaves it in place.

Two things to fix (see inline comments):

  • the package name in that import
  • a corrupt mcp-auth.json fails the migration instead of being skipped

Related: #53607

Comment thread packages/core/src/mcp/integration-id.ts Outdated
@@ -0,0 +1,11 @@
import { createHash } from "node:crypto"
import { Integration } from "@opencode-ai/schema/integration"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This package is @opencode/schema in this repo; @opencode-ai/schema doesn't exist. As submitted, the CLI server exits on startup (Cannot find package '@opencode-ai/schema'), bun run typecheck fails with TS2307, and test/database-migration.test.ts can't load. With @opencode/schema/integration, everything passes and the #54879 repro connects using the imported V1 token.

if (content === undefined) return
const input = Option.getOrUndefined(decodeJson(content))
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return yield* Effect.fail(new Error("Legacy MCP auth file must contain an object"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If mcp-auth.json isn't valid JSON or isn't an object, this fails the migration. The migration would then fail again on every startup, all because of a leftover V1 file that a user can easily fix by running opencode mcp auth. V1 wrote this file without locking, so a corrupt copy is a real possibility (see #29820). I'd return here instead of failing, the same way malformed entries are already skipped. A small test with a corrupt file would cover it.

`@opencode-ai/schema` is not a package in this repo: the workspace
packages are named `@opencode/*`. The import only resolved locally
because a stale pre-rename symlink survives in node_modules, so a fresh
checkout failed to start the CLI server and failed to typecheck.
The import runs on every startup, so failing on a file the user cannot
see would keep the database shut. Malformed entries are already skipped;
the whole-file case now behaves the same way.
@argszero

Copy link
Copy Markdown
Contributor Author

Both points are fixed and pushed.

1. The import — 5d1f69b11c. @opencode-ai/schema is not a package in this repo; the workspace packages are @opencode/*. Changed to @opencode/schema/integration.

Worth recording why this passed every gate locally and still broke on a fresh checkout: packages/core/node_modules/@opencode-ai/schema is a stale pre-rename symlink left over in node_modules (dated well before this branch), so on this machine both tsgo and the tests resolved the import, while a clean install has no such path. I reproduced your two symptoms directly — with the old specifier, Cannot find package at startup and test/database-migration.test.ts unable to load — and confirmed no file in the diff still imports the old alias.

2. The corrupt file — 6b37a8a3ff. You're right that failing is the worse outcome: this migration re-runs on every startup, so one leftover malformed file would keep the database shut until the user found a file they cannot see. It now returns and skips, which is what the per-entry path already did for malformed entries. The file is still left in place for the user to repair. New test covers both an unparseable file and parseable-but-not-an-object.

Gates re-run on the pushed head:

  • tsgo -b tsconfig.json tsconfig.tests.json in packages/core — clean
  • test/database-migration.test.ts — 22 pass / 0 fail; the new corrupt-file test fails without the fix (21 pass / 1 fail), so it is a real regression guard
  • test/mcp — 103 pass / 2 fail, and the same two fail identically on the unmodified head, so they are pre-existing and unrelated
  • oxlint on the changed files — 0 warnings / 0 errors

@argszero

Copy link
Copy Markdown
Contributor Author

Closing this to stay within GitHub's 3-open-pull-request limit for contributors without write access, which currently blocks every new pull request here. The branch remains on my fork, so I can reopen it on request.

@argszero argszero closed this Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant