History

Merge pull request #360 from RhysSullivan/rs/connections-mcp-migrate

Rhys Sullivan committed 6 months ago

Commit aa220d72 parents 22a8c1b e78249c5 files · +777 −138Browse files

Unified · Split

5 files
  1. apps/cloud/scripts/migrate-mcp-connections.ts +332 −0
  2. apps/local/src/server/executor.ts +5 −4
  3. apps/local/src/server/migrate-connections.ts +425 −133
  4. packages/plugins/mcp/src/sdk/index.ts +2 −0
  5. packages/plugins/mcp/src/sdk/plugin.ts +13 −1
apps/cloud/scripts/migrate-mcp-connections.ts+332−0
@@ -0,0 +1,332 @@
1+// ---------------------------------------------------------------------------
2+// MCP OAuth legacy → Connection backfill (cloud)
3+// ---------------------------------------------------------------------------
4+//
5+// Companion to migrate-connections.ts (which handled OpenAPI). Same shape,
6+// different table: scans `mcp_source`, finds rows whose `config.auth` is
7+// still on the pre-refactor inline-OAuth shape, mints a stable per-source
8+// Connection row, backfills the secret routing rows, and rewrites
9+// `config.auth` to the `{kind:"oauth2", connectionId}` pointer.
10+//
11+// Dry-run by default. `--apply` runs the per-row transactions.
12+//
13+// Run (dry-run):
14+// op run --env-file=.env.production -- bun run scripts/migrate-mcp-connections.ts
15+// Run (apply):
16+// op run --env-file=.env.production -- bun run scripts/migrate-mcp-connections.ts --apply
17+
18+import { Option, Schema } from "effect";
19+import postgres from "postgres";
20+
21+import { McpConnectionAuth } from "@executor/plugin-mcp";
22+
23+const APPLY = process.argv.includes("--apply");
24+const DUMP_BLOCKED = process.argv.includes("--dump-blocked");
25+
26+// ---------------------------------------------------------------------------
27+// Legacy MCP oauth2 auth shape (pre-refactor). Inlined — this script is the
28+// only place that still needs to know about it. Once cloud + local have
29+// run this migration, the file can be deleted.
30+// ---------------------------------------------------------------------------
31+
32+const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
33+
34+const LegacyMcpOAuth2 = Schema.Struct({
35+ kind: Schema.Literal("oauth2"),
36+ accessTokenSecretId: Schema.String,
37+ refreshTokenSecretId: Schema.NullOr(Schema.String),
38+ tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
39+ expiresAt: Schema.NullOr(Schema.Number),
40+ scope: Schema.NullOr(Schema.String),
41+ clientInformation: Schema.optionalWith(Schema.NullOr(JsonObject), {
42+ default: () => null,
43+ }),
44+ authorizationServerUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
45+ default: () => null,
46+ }),
47+ resourceMetadataUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
48+ default: () => null,
49+ }),
50+});
51+type LegacyMcpOAuth2 = typeof LegacyMcpOAuth2.Type;
52+
53+const decodeCurrentAuth = Schema.decodeUnknownOption(McpConnectionAuth);
54+const decodeLegacy = Schema.decodeUnknownOption(LegacyMcpOAuth2);
55+
56+const isRecord = (v: unknown): v is Record<string, unknown> =>
57+ typeof v === "object" && v !== null && !Array.isArray(v);
58+
59+// ---------------------------------------------------------------------------
60+// Row classification
61+// ---------------------------------------------------------------------------
62+
63+type Row = {
64+ scope_id: string;
65+ id: string;
66+ name: string;
67+ config: unknown;
68+};
69+
70+type Bucket =
71+ | { kind: "non-remote"; row: Row }
72+ | { kind: "no-oauth"; row: Row }
73+ | { kind: "current"; row: Row }
74+ | { kind: "legacy-migratable"; row: Row; legacy: LegacyMcpOAuth2; endpoint: string }
75+ | { kind: "legacy-blocked"; row: Row; legacy: LegacyMcpOAuth2; reason: string }
76+ | { kind: "unknown"; row: Row; shape: string };
77+
78+const classifyRow = (row: Row): Bucket => {
79+ if (!isRecord(row.config)) return { kind: "unknown", row, shape: typeof row.config };
80+ if (row.config.transport !== "remote") return { kind: "non-remote", row };
81+ const auth = row.config.auth;
82+ if (!isRecord(auth)) return { kind: "no-oauth", row };
83+ if (auth.kind !== "oauth2") return { kind: "no-oauth", row };
84+
85+ if (Option.isSome(decodeCurrentAuth(auth))) return { kind: "current", row };
86+
87+ const legacyOption = decodeLegacy(auth);
88+ if (Option.isSome(legacyOption)) {
89+ const legacy = legacyOption.value;
90+ const endpoint =
91+ typeof row.config.endpoint === "string" ? row.config.endpoint : null;
92+ if (!endpoint) {
93+ return {
94+ kind: "legacy-blocked",
95+ row,
96+ legacy,
97+ reason: "config.endpoint missing",
98+ };
99+ }
100+ return { kind: "legacy-migratable", row, legacy, endpoint };
101+ }
102+
103+ const shape = `{${Object.keys(auth).sort().join(",")}}`;
104+ return { kind: "unknown", row, shape };
105+};
106+
107+// ---------------------------------------------------------------------------
108+// Main
109+// ---------------------------------------------------------------------------
110+
111+type SecretRow = {
112+ id: string;
113+ scope_id: string;
114+ owned_by_connection_id: string | null;
115+};
116+
117+const main = async () => {
118+ const connectionString =
119+ process.env.DATABASE_URL || process.env.HYPERDRIVE_CONNECTION_STRING || "";
120+ if (!connectionString) {
121+ console.error(
122+ "DATABASE_URL not set (try: op run --env-file=.env.production -- ...)",
123+ );
124+ process.exit(1);
125+ }
126+
127+ const sql = postgres(connectionString, {
128+ max: 1,
129+ onnotice: () => undefined,
130+ ssl: "require",
131+ });
132+
133+ try {
134+ const rows = (await sql<Row[]>`
135+ select scope_id, id, name, config
136+ from mcp_source
137+ `) as Row[];
138+
139+ console.log(`\nScanned ${rows.length} mcp_source row(s)`);
140+ console.log(APPLY ? "Mode: APPLY (writes enabled)\n" : "Mode: DRY-RUN (no writes)\n");
141+
142+ const buckets = rows.map(classifyRow);
143+
144+ const counts = {
145+ "non-remote": 0,
146+ "no-oauth": 0,
147+ current: 0,
148+ "legacy-migratable": 0,
149+ "legacy-blocked": 0,
150+ unknown: 0,
151+ };
152+ for (const b of buckets) counts[b.kind]++;
153+
154+ console.log("Classification:");
155+ console.log(` non-remote (stdio): ${counts["non-remote"]}`);
156+ console.log(` no oauth2 auth: ${counts["no-oauth"]}`);
157+ console.log(` already on new shape: ${counts.current}`);
158+ console.log(` legacy — would migrate: ${counts["legacy-migratable"]}`);
159+ console.log(` legacy — blocked: ${counts["legacy-blocked"]}`);
160+ console.log(` unrecognized shape: ${counts.unknown}\n`);
161+
162+ const migratable = buckets.filter(
163+ (b): b is Extract<Bucket, { kind: "legacy-migratable" }> =>
164+ b.kind === "legacy-migratable",
165+ );
166+ const blocked = buckets.filter(
167+ (b) => b.kind === "legacy-blocked" || b.kind === "unknown",
168+ );
169+
170+ for (const b of blocked) {
171+ const ref = `${b.row.scope_id}/${b.row.id}`;
172+ if (b.kind === "legacy-blocked") {
173+ console.log(`[BLOCKED] ${ref}`);
174+ console.log(` reason: ${b.reason}`);
175+ } else if (b.kind === "unknown") {
176+ console.log(`[UNKNOWN] ${ref}`);
177+ console.log(` shape: ${b.shape}`);
178+ }
179+ if (DUMP_BLOCKED && isRecord(b.row.config)) {
180+ console.log(` auth: ${JSON.stringify(b.row.config.auth, null, 2)}`);
181+ }
182+ console.log();
183+ }
184+
185+ for (const b of migratable) {
186+ const ref = `${b.row.scope_id}/${b.row.id}`;
187+ console.log(`[migratable] ${ref}`);
188+ console.log(` endpoint: ${b.endpoint}`);
189+ console.log(` accessTokenSecret: ${b.legacy.accessTokenSecretId}`);
190+ console.log(
191+ ` refreshTokenSecret: ${b.legacy.refreshTokenSecretId ?? "(null)"}`,
192+ );
193+ console.log(
194+ ` authServerUrl: ${b.legacy.authorizationServerUrl ?? "(null)"}`,
195+ );
196+ console.log();
197+ }
198+
199+ if (blocked.length > 0) {
200+ console.log(
201+ `ABORT: ${blocked.length} row(s) blocked or unrecognized; inspect above and fix or delete before re-running.`,
202+ );
203+ process.exit(2);
204+ }
205+
206+ if (!APPLY) {
207+ console.log(`OK: ${migratable.length} row(s) would migrate. Re-run with --apply.`);
208+ return;
209+ }
210+
211+ if (migratable.length === 0) {
212+ console.log("Nothing to migrate.");
213+ return;
214+ }
215+
216+ let applied = 0;
217+ let failed = 0;
218+ for (const b of migratable) {
219+ const ref = `${b.row.scope_id}/${b.row.id}`;
220+ const connectionId = `mcp-oauth2-${b.row.id}`;
221+ const l = b.legacy;
222+ const providerState = {
223+ endpoint: b.endpoint,
224+ tokenType: l.tokenType,
225+ clientInformation: l.clientInformation,
226+ authorizationServerUrl: l.authorizationServerUrl,
227+ authorizationServerMetadata: null,
228+ resourceMetadataUrl: l.resourceMetadataUrl,
229+ resourceMetadata: null,
230+ };
231+ const authPointer = { kind: "oauth2" as const, connectionId };
232+ const nextConfig = { ...(isRecord(b.row.config) ? b.row.config : {}), auth: authPointer };
233+
234+ try {
235+ await sql.begin(async (tx) => {
236+ await tx`
237+ insert into connection (
238+ id, scope_id, provider, kind, identity_label,
239+ access_token_secret_id, refresh_token_secret_id,
240+ expires_at, scope, provider_state,
241+ created_at, updated_at
242+ ) values (
243+ ${connectionId},
244+ ${b.row.scope_id},
245+ ${"mcp:oauth2"},
246+ ${"user"},
247+ ${b.row.name},
248+ ${l.accessTokenSecretId},
249+ ${l.refreshTokenSecretId},
250+ ${l.expiresAt},
251+ ${l.scope},
252+ ${tx.json(providerState)},
253+ now(),
254+ now()
255+ )
256+ `;
257+
258+ const secretIds = [l.accessTokenSecretId];
259+ if (l.refreshTokenSecretId) secretIds.push(l.refreshTokenSecretId);
260+
261+ const existing = (await tx<SecretRow[]>`
262+ select id, scope_id, owned_by_connection_id
263+ from secret
264+ where scope_id = ${b.row.scope_id} and id = any(${secretIds})
265+ `) as SecretRow[];
266+ const alreadyOwned = existing.filter(
267+ (r) =>
268+ r.owned_by_connection_id !== null &&
269+ r.owned_by_connection_id !== connectionId,
270+ );
271+ if (alreadyOwned.length > 0) {
272+ throw new Error(
273+ `secret(s) already owned: ${alreadyOwned.map((r) => `${r.id}(owner=${r.owned_by_connection_id})`).join(", ")}`,
274+ );
275+ }
276+ // Backfill any missing routing rows pointing at workos-vault, the
277+ // only writable provider on cloud. Matches the openapi migration.
278+ const missing = secretIds.filter(
279+ (id) => !existing.some((r) => r.id === id),
280+ );
281+ for (const id of missing) {
282+ const name =
283+ id === l.accessTokenSecretId
284+ ? `Connection ${connectionId} access token`
285+ : `Connection ${connectionId} refresh token`;
286+ await tx`
287+ insert into secret (
288+ id, scope_id, provider, name,
289+ owned_by_connection_id, created_at
290+ ) values (
291+ ${id}, ${b.row.scope_id}, ${"workos-vault"}, ${name},
292+ ${connectionId}, now()
293+ )
294+ `;
295+ }
296+ if (existing.length > 0) {
297+ await tx`
298+ update secret
299+ set owned_by_connection_id = ${connectionId}
300+ where scope_id = ${b.row.scope_id} and id = any(${secretIds})
301+ `;
302+ }
303+
304+ await tx`
305+ update mcp_source
306+ set config = ${tx.json(nextConfig)}
307+ where scope_id = ${b.row.scope_id} and id = ${b.row.id}
308+ `;
309+ });
310+ applied++;
311+ console.log(` [OK] ${ref} -> ${connectionId}`);
312+ } catch (err) {
313+ failed++;
314+ console.log(
315+ ` [FAIL] ${ref}: ${err instanceof Error ? err.message : String(err)}`,
316+ );
317+ }
318+ }
319+
320+ console.log();
321+ console.log(`Applied: ${applied}`);
322+ console.log(`Failed: ${failed}`);
323+ if (failed > 0) process.exit(3);
324+ } finally {
325+ await sql.end({ timeout: 5 });
326+ }
327+};
328+
329+main().catch((err) => {
330+ console.error("migrate-mcp-connections failed:", err);
331+ process.exit(1);
332+});
apps/local/src/server/executor.ts+5−4
@@ -14,7 +14,7 @@ import { · 13 unmodified lines
1414 readLegacySecrets,
1515 type LegacySecret,
1616 } from "./db-upgrade";
17−import { migrateOpenApiOAuthConnections } from "./migrate-connections";
17+import { migrateLegacyConnections } from "./migrate-connections";
1818
1919 import {
2020 Scope,
@@ -137,9 +137,10 @@ const createLocalExecutorLayer = () => { · 116 unmodified lines
137137 if (legacySecrets.length > 0) {
138138 importLegacySecrets(sqlite, scopeId, legacySecrets);
139139 }
140− // Upgrade pre-connection OpenAPI OAuth rows onto the new Connection
141− // pointer shape. Idempotent + no-op when there's nothing to migrate.
142− yield* Effect.promise(() => migrateOpenApiOAuthConnections(sqlite));
140+ // Upgrade pre-connection openapi/mcp/google-discovery rows onto the
141+ // new Connection pointer shape. Idempotent + no-op when there's
142+ // nothing to migrate.
143+ yield* Effect.promise(() => migrateLegacyConnections(sqlite));
143144 const configPath = resolveConfigPath(cwd);
144145 const configFile = makeFileConfigSink({
145146 path: configPath,
apps/local/src/server/migrate-connections.ts+425−133
@@ -1,16 +1,16 @@
11 // ---------------------------------------------------------------------------
2−// OpenAPI OAuth legacy → Connection backfill (local)
2+// OAuth legacy → Connection backfill (local)
33 // ---------------------------------------------------------------------------
44 //
5−// Runs at boot time right after `migrate()`. For every `openapi_source`
6−// row still on the pre-refactor OAuth2 shape, mints a Connection row,
7−// re-parents the referenced secret(s) to it, and rewrites both the
8−// top-level `oauth2` column and the nested `invocation_config.oauth2`
9−// copy to the new pointer shape.
5+// Runs at boot time right after `migrate()`. For every row still on the
6+// pre-refactor inline-OAuth shape (openapi_source, mcp_source,
7+// google_discovery_source), mints a Connection row, re-parents the
8+// referenced secret(s) to it, and rewrites the source's stored auth to
9+// the new pointer shape.
1010 //
1111 // Self-contained: the only plugin imports are current-shape parsing
12−// helpers. The pre-refactor shape is defined inline — this file is the
13−// last place in the codebase that still needs to know about it.
12+// helpers. Each legacy shape is defined inline — this file is the last
13+// place in the codebase that still needs to know about them.
1414
1515 import { Database } from "bun:sqlite";
1616 import { randomUUID } from "node:crypto";
@@ -21,10 +21,150 @@ import { · 4 unmodified lines
2121 resolveSpecText,
2222 OAuth2Auth,
2323 } from "@executor/plugin-openapi";
24+import { McpConnectionAuth } from "@executor/plugin-mcp";
25+
26+// ---------------------------------------------------------------------------
27+// Shared helpers
28+// ---------------------------------------------------------------------------
29+
30+const isRecord = (v: unknown): v is Record<string, unknown> =>
31+ typeof v === "object" && v !== null && !Array.isArray(v);
32+const isString = (v: unknown): v is string => typeof v === "string";
33+
34+const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
35+
36+/** Pre-flight: bail unless the drizzle migration that added the Connection
37+ * table + `secret.owned_by_connection_id` has completed. */
38+const connectionsReady = (sqlite: Database): boolean => {
39+ const secretColumns = sqlite
40+ .prepare("PRAGMA table_info('secret')")
41+ .all() as ReadonlyArray<{ readonly name: string }>;
42+ if (!secretColumns.some((c) => c.name === "owned_by_connection_id")) return false;
43+ const connectionTable = sqlite
44+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='connection'")
45+ .get();
46+ return connectionTable !== null && connectionTable !== undefined;
47+};
48+
49+const tableExists = (sqlite: Database, name: string): boolean => {
50+ const row = sqlite
51+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
52+ .get(name);
53+ return row !== null && row !== undefined;
54+};
55+
56+type SecretRow = { id: string; owned_by_connection_id: string | null };
57+
58+/** Shared: re-parent the pointed-to secret ids to the new connection,
59+ * backfilling any missing routing rows. Returns `null` on success, an
60+ * error message string on skip. */
61+const rewireSecrets = (
62+ sqlite: Database,
63+ scopeId: string,
64+ connectionId: string,
65+ secretIds: ReadonlyArray<string>,
66+ namesByIndex: ReadonlyArray<string>,
67+): string | null => {
68+ const selectSecret = sqlite.prepare(
69+ "SELECT id, owned_by_connection_id FROM secret WHERE scope_id = ? AND id = ?",
70+ );
71+ const selectAnySecretProvider = sqlite.prepare(
72+ "SELECT provider FROM secret WHERE scope_id = ? LIMIT 1",
73+ );
74+ const updateSecretOwner = sqlite.prepare(
75+ "UPDATE secret SET owned_by_connection_id = ? WHERE scope_id = ? AND id = ?",
76+ );
77+ const insertSecret = sqlite.prepare(
78+ `INSERT INTO secret (
79+ id, scope_id, provider, name,
80+ owned_by_connection_id, created_at
81+ ) VALUES (?, ?, ?, ?, ?, ?)`,
82+ );
83+
84+ const rows = secretIds.map(
85+ (sid) => selectSecret.get(scopeId, sid) as SecretRow | undefined,
86+ );
87+ const alreadyOwned = rows
88+ .filter((r): r is SecretRow => !!r)
89+ .filter(
90+ (r) =>
91+ r.owned_by_connection_id !== null &&
92+ r.owned_by_connection_id !== connectionId,
93+ );
94+ if (alreadyOwned.length > 0) return "secret(s) already owned";
95+
96+ // Early-onboarded rows never got a `secret` routing row — pre-refactor
97+ // `secretsGet` resolved them via provider enumeration. Pick the
98+ // provider already in use at this scope (or fall back to keychain) so
99+ // the new id-indexed fast path resolves. If we guess wrong the SDK's
100+ // enumerate-fallback still works.
101+ const missingCount = rows.filter((r) => r === undefined).length;
102+ let fallbackProvider: string | null = null;
103+ if (missingCount > 0) {
104+ const existing = selectAnySecretProvider.get(scopeId) as
105+ | { provider: string }
106+ | undefined;
107+ fallbackProvider = existing?.provider ?? "keychain";
108+ }
109+
110+ const now = Date.now();
111+ for (let i = 0; i < secretIds.length; i++) {
112+ const sid = secretIds[i]!;
113+ if (rows[i] === undefined) {
114+ insertSecret.run(sid, scopeId, fallbackProvider!, namesByIndex[i]!, connectionId, now);
115+ } else {
116+ updateSecretOwner.run(connectionId, scopeId, sid);
117+ }
118+ }
119+ return null;
120+};
121+
122+const insertConnectionRow = (
123+ sqlite: Database,
124+ params: {
125+ id: string;
126+ scopeId: string;
127+ provider: string;
128+ identityLabel: string;
129+ accessTokenSecretId: string;
130+ refreshTokenSecretId: string | null;
131+ expiresAt: number | null;
132+ scope: string | null;
133+ providerState: unknown;
134+ },
135+): void => {
136+ const stmt = sqlite.prepare(
137+ `INSERT INTO connection (
138+ id, scope_id, provider, kind, identity_label,
139+ access_token_secret_id, refresh_token_secret_id,
140+ expires_at, scope, provider_state,
141+ created_at, updated_at
142+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
143+ );
144+ const now = Date.now();
145+ stmt.run(
146+ params.id,
147+ params.scopeId,
148+ params.provider,
149+ "user",
150+ params.identityLabel,
151+ params.accessTokenSecretId,
152+ params.refreshTokenSecretId,
153+ params.expiresAt,
154+ params.scope,
155+ JSON.stringify(params.providerState),
156+ now,
157+ now,
158+ );
159+};
160+
161+// ---------------------------------------------------------------------------
162+// OpenAPI — legacy shape
163+// ---------------------------------------------------------------------------
24164
25165 const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials");
26166
27−class LegacyOAuth2Auth extends Schema.Class<LegacyOAuth2Auth>("LegacyOAuth2Auth")({
167+class LegacyOpenApiOAuth2 extends Schema.Class<LegacyOpenApiOAuth2>("LegacyOpenApiOAuth2")({
28168 kind: Schema.Literal("oauth2"),
29169 securitySchemeName: Schema.String,
30170 flow: OAuth2Flow,
@@ -39,12 +179,8 @@ class LegacyOAuth2Auth extends Schema.Class<LegacyOAuth2Auth>("LegacyOAuth2Auth" · 8 unmodified lines
39179 scopes: Schema.Array(Schema.String),
40180 }) {}
41181
42−const decodeCurrent = Schema.decodeUnknownOption(OAuth2Auth);
43−const decodeLegacy = Schema.decodeUnknownOption(LegacyOAuth2Auth);
44−
45−const isRecord = (v: unknown): v is Record<string, unknown> =>
46− typeof v === "object" && v !== null && !Array.isArray(v);
47−const isString = (v: unknown): v is string => typeof v === "string";
182+const decodeOpenApiCurrent = Schema.decodeUnknownOption(OAuth2Auth);
183+const decodeOpenApiLegacy = Schema.decodeUnknownOption(LegacyOpenApiOAuth2);
48184
49185 const extractAuthorizationUrl = async (
50186 rawSpec: string,
@@ -78,7 +214,7 @@ const extractAuthorizationUrl = async ( · 27 unmodified lines
78214 : null;
79215 };
80216
81−type Row = {
217+type OpenApiRow = {
82218 scope_id: string;
83219 id: string;
84220 name: string;
@@ -87,56 +223,15 @@ type Row = { · 2 unmodified lines
87223 oauth2: string | null;
88224 };
89225
90−/**
91− * Scan `openapi_source`, migrate any row still on the legacy OAuth2 shape
92− * to a fresh Connection row + pointer. Idempotent — rows already on the
93− * current shape are skipped. Logs one line per migrated row.
94− */
95−export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise<void> => {
96− // The 0002_lively_sue_storm migration introduced the `connection` table
97− // and added `secret.owned_by_connection_id`. If those aren't present yet,
98− // the drizzle `migrate()` call upstream hasn't finished — bail.
99− const secretColumns = sqlite
100− .prepare("PRAGMA table_info('secret')")
101− .all() as ReadonlyArray<{ readonly name: string }>;
102− if (!secretColumns.some((c) => c.name === "owned_by_connection_id")) {
103− return;
104− }
105− const connectionTable = sqlite
106− .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='connection'")
107− .get();
108− if (!connectionTable) return;
109−
226+const migrateOpenApi = async (sqlite: Database): Promise<void> => {
227+ if (!tableExists(sqlite, "openapi_source")) return;
110228 const rows = sqlite
111229 .prepare(
112230 "SELECT scope_id, id, name, spec, invocation_config, oauth2 FROM openapi_source",
113231 )
114− .all() as ReadonlyArray<Row>;
232+ .all() as ReadonlyArray<OpenApiRow>;
115233 if (rows.length === 0) return;
116234
117− const insertConnection = sqlite.prepare(
118− `INSERT INTO connection (
119− id, scope_id, provider, kind, identity_label,
120− access_token_secret_id, refresh_token_secret_id,
121− expires_at, scope, provider_state,
122− created_at, updated_at
123− ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
124− );
125− const updateSecretOwner = sqlite.prepare(
126− "UPDATE secret SET owned_by_connection_id = ? WHERE scope_id = ? AND id = ?",
127− );
128− const selectSecret = sqlite.prepare(
129− "SELECT id, owned_by_connection_id FROM secret WHERE scope_id = ? AND id = ?",
130− );
131− const selectAnySecretProvider = sqlite.prepare(
132− "SELECT provider FROM secret WHERE scope_id = ? LIMIT 1",
133− );
134− const insertSecret = sqlite.prepare(
135− `INSERT INTO secret (
136− id, scope_id, provider, name,
137− owned_by_connection_id, created_at
138− ) VALUES (?, ?, ?, ?, ?, ?)`,
139− );
140235 const updateSource = sqlite.prepare(
141236 "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?",
142237 );
@@ -161,9 +256,9 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 18 unmodified lines
161256 }
162257 const primary = invocation.oauth2 ?? oauth2Col;
163258 if (primary == null) continue;
164− if (Option.isSome(decodeCurrent(primary))) continue;
259+ if (Option.isSome(decodeOpenApiCurrent(primary))) continue;
165260
166− const legacyOption = decodeLegacy(primary);
261+ const legacyOption = decodeOpenApiLegacy(primary);
167262 if (Option.isNone(legacyOption)) continue;
168263 const legacy = legacyOption.value;
169264
@@ -174,7 +269,7 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 4 unmodified lines
174269 );
175270 if (legacy.flow === "authorizationCode" && authorizationUrl === null) {
176271 console.warn(
177− `[migrate-connections] skip ${row.scope_id}/${row.id}: authorizationCode flow but authorizationUrl unavailable`,
272+ `[migrate-connections] skip openapi ${row.scope_id}/${row.id}: authorizationCode flow but authorizationUrl unavailable`,
178273 );
179274 continue;
180275 }
@@ -200,95 +295,292 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 19 unmodified lines
200295 };
201296
202297 const secretIds = [legacy.accessTokenSecretId];
203− if (legacy.refreshTokenSecretId) secretIds.push(legacy.refreshTokenSecretId);
298+ const secretNames = [`Connection ${connectionId} access token`];
299+ if (legacy.refreshTokenSecretId) {
300+ secretIds.push(legacy.refreshTokenSecretId);
301+ secretNames.push(`Connection ${connectionId} refresh token`);
302+ }
204303
205− const secretRows = secretIds.map(
206− (sid) =>
207− selectSecret.get(row.scope_id, sid) as
208− | { id: string; owned_by_connection_id: string | null }
209− | undefined,
210− );
211− const alreadyOwned = secretRows
212− .filter((s): s is { id: string; owned_by_connection_id: string | null } => !!s)
213− .filter(
214− (s) =>
215− s.owned_by_connection_id !== null &&
216− s.owned_by_connection_id !== connectionId,
304+ const txn = sqlite.transaction(() => {
305+ insertConnectionRow(sqlite, {
306+ id: connectionId,
307+ scopeId: row.scope_id,
308+ provider: "openapi:oauth2",
309+ identityLabel: row.name,
310+ accessTokenSecretId: legacy.accessTokenSecretId,
311+ refreshTokenSecretId: legacy.refreshTokenSecretId,
312+ expiresAt: legacy.expiresAt,
313+ scope: legacy.scope,
314+ providerState,
315+ });
316+ const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);
317+ if (err) throw new Error(err);
318+ const nextInvocation = { ...invocation, oauth2: oauth2Pointer };
319+ updateSource.run(
320+ JSON.stringify(oauth2Pointer),
321+ JSON.stringify(nextInvocation),
322+ row.scope_id,
323+ row.id,
324+ );
325+ });
326+ try {
327+ txn();
328+ console.log(
329+ `[migrate-connections] openapi ${row.scope_id}/${row.id} -> ${connectionId}`,
217330 );
218− if (alreadyOwned.length > 0) {
331+ } catch (err) {
219332 console.warn(
220− `[migrate-connections] skip ${row.scope_id}/${row.id}: secret(s) already owned`,
333+ `[migrate-connections] fail openapi ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`,
221334 );
335+ }
336+ }
337+};
338+
339+// ---------------------------------------------------------------------------
340+// MCP — legacy shape
341+// ---------------------------------------------------------------------------
342+
343+const LegacyMcpOAuth2 = Schema.Struct({
344+ kind: Schema.Literal("oauth2"),
345+ accessTokenSecretId: Schema.String,
346+ refreshTokenSecretId: Schema.NullOr(Schema.String),
347+ tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
348+ expiresAt: Schema.NullOr(Schema.Number),
349+ scope: Schema.NullOr(Schema.String),
350+ clientInformation: Schema.optionalWith(Schema.NullOr(JsonObject), {
351+ default: () => null,
352+ }),
353+ authorizationServerUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
354+ default: () => null,
355+ }),
356+ resourceMetadataUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
357+ default: () => null,
358+ }),
359+});
360+
361+const decodeMcpCurrent = Schema.decodeUnknownOption(McpConnectionAuth);
362+const decodeMcpLegacy = Schema.decodeUnknownOption(LegacyMcpOAuth2);
363+
364+type McpRow = {
365+ scope_id: string;
366+ id: string;
367+ name: string;
368+ config: string;
369+};
370+
371+const migrateMcp = (sqlite: Database): void => {
372+ if (!tableExists(sqlite, "mcp_source")) return;
373+ const rows = sqlite
374+ .prepare("SELECT scope_id, id, name, config FROM mcp_source")
375+ .all() as ReadonlyArray<McpRow>;
376+ if (rows.length === 0) return;
377+
378+ const updateSource = sqlite.prepare(
379+ "UPDATE mcp_source SET config = ? WHERE scope_id = ? AND id = ?",
380+ );
381+
382+ for (const row of rows) {
383+ let config: Record<string, unknown> = {};
384+ try {
385+ const parsed = JSON.parse(row.config) as unknown;
386+ if (!isRecord(parsed)) continue;
387+ config = parsed;
388+ } catch {
222389 continue;
223390 }
224− // Early-onboarded OpenAPI OAuth tokens never got a `secret` routing
225− // row — pre-refactor `secretsGet` resolved them via provider
226− // enumeration. Pick the provider already in use at this scope (or
227− // fall back to keychain) so the new id-indexed fast path resolves;
228− // if we guess wrong the SDK's enumerate-fallback still works.
229− const missingIndexes = secretIds
230− .map((_, i) => i)
231− .filter((i) => secretRows[i] === undefined);
232− let fallbackProvider: string | null = null;
233− if (missingIndexes.length > 0) {
234− const existing = selectAnySecretProvider.get(row.scope_id) as
235− | { provider: string }
236− | undefined;
237− fallbackProvider = existing?.provider ?? "keychain";
391+ if (config.transport !== "remote") continue;
392+ const auth = config.auth;
393+ if (!isRecord(auth) || auth.kind !== "oauth2") continue;
394+
395+ if (Option.isSome(decodeMcpCurrent(auth))) continue;
396+
397+ const legacyOption = decodeMcpLegacy(auth);
398+ if (Option.isNone(legacyOption)) continue;
399+ const legacy = legacyOption.value;
400+
401+ const endpoint = typeof config.endpoint === "string" ? config.endpoint : null;
402+ if (!endpoint) {
403+ console.warn(
404+ `[migrate-connections] skip mcp ${row.scope_id}/${row.id}: endpoint missing`,
405+ );
406+ continue;
407+ }
408+ const connectionId = `mcp-oauth2-${row.id}`;
409+ const providerState = {
410+ endpoint,
411+ tokenType: legacy.tokenType,
412+ clientInformation: legacy.clientInformation,
413+ authorizationServerUrl: legacy.authorizationServerUrl,
414+ authorizationServerMetadata: null,
415+ resourceMetadataUrl: legacy.resourceMetadataUrl,
416+ resourceMetadata: null,
417+ };
418+ const authPointer = { kind: "oauth2" as const, connectionId };
419+ const nextConfig = { ...config, auth: authPointer };
420+
421+ const secretIds = [legacy.accessTokenSecretId];
422+ const secretNames = [`Connection ${connectionId} access token`];
423+ if (legacy.refreshTokenSecretId) {
424+ secretIds.push(legacy.refreshTokenSecretId);
425+ secretNames.push(`Connection ${connectionId} refresh token`);
238426 }
239427
240− const now = Date.now();
241428 const txn = sqlite.transaction(() => {
242− insertConnection.run(
243− connectionId,
244− row.scope_id,
245− "openapi:oauth2",
246− "user",
247− row.name,
248− legacy.accessTokenSecretId,
249− legacy.refreshTokenSecretId,
250− legacy.expiresAt,
251− legacy.scope,
252− JSON.stringify(providerState),
253− now,
254− now,
429+ insertConnectionRow(sqlite, {
430+ id: connectionId,
431+ scopeId: row.scope_id,
432+ provider: "mcp:oauth2",
433+ identityLabel: row.name,
434+ accessTokenSecretId: legacy.accessTokenSecretId,
435+ refreshTokenSecretId: legacy.refreshTokenSecretId,
436+ expiresAt: legacy.expiresAt,
437+ scope: legacy.scope,
438+ providerState,
439+ });
440+ const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);
441+ if (err) throw new Error(err);
442+ updateSource.run(JSON.stringify(nextConfig), row.scope_id, row.id);
443+ });
444+ try {
445+ txn();
446+ console.log(
447+ `[migrate-connections] mcp ${row.scope_id}/${row.id} -> ${connectionId}`,
255448 );
256− for (let i = 0; i < secretIds.length; i++) {
257− const sid = secretIds[i]!;
258− if (secretRows[i] === undefined) {
259− const name =
260− sid === legacy.accessTokenSecretId
261− ? `Connection ${connectionId} access token`
262− : `Connection ${connectionId} refresh token`;
263− insertSecret.run(
264− sid,
265− row.scope_id,
266− fallbackProvider!,
267− name,
268− connectionId,
269− now,
270− );
271− } else {
272− updateSecretOwner.run(connectionId, row.scope_id, sid);
273− }
274− }
275− const nextInvocation = { ...invocation, oauth2: oauth2Pointer };
276− updateSource.run(
277− JSON.stringify(oauth2Pointer),
278− JSON.stringify(nextInvocation),
279− row.scope_id,
280− row.id,
449+ } catch (err) {
450+ console.warn(
451+ `[migrate-connections] fail mcp ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`,
281452 );
453+ }
454+ }
455+};
456+
457+// ---------------------------------------------------------------------------
458+// google-discovery — legacy shape
459+// ---------------------------------------------------------------------------
460+
461+const LegacyGoogleDiscoveryOAuth2 = Schema.Struct({
462+ kind: Schema.Literal("oauth2"),
463+ clientIdSecretId: Schema.String,
464+ clientSecretSecretId: Schema.NullOr(Schema.String),
465+ accessTokenSecretId: Schema.String,
466+ refreshTokenSecretId: Schema.NullOr(Schema.String),
467+ tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
468+ expiresAt: Schema.NullOr(Schema.Number),
469+ scope: Schema.NullOr(Schema.String),
470+ scopes: Schema.Array(Schema.String),
471+});
472+
473+const CurrentGoogleDiscoveryOAuth2 = Schema.Struct({
474+ kind: Schema.Literal("oauth2"),
475+ connectionId: Schema.String,
476+ clientIdSecretId: Schema.String,
477+ clientSecretSecretId: Schema.NullOr(Schema.String),
478+ scopes: Schema.Array(Schema.String),
479+});
480+
481+const decodeGoogleCurrent = Schema.decodeUnknownOption(CurrentGoogleDiscoveryOAuth2);
482+const decodeGoogleLegacy = Schema.decodeUnknownOption(LegacyGoogleDiscoveryOAuth2);
483+
484+type GoogleRow = {
485+ scope_id: string;
486+ id: string;
487+ name: string;
488+ config: string;
489+};
490+
491+const migrateGoogleDiscovery = (sqlite: Database): void => {
492+ if (!tableExists(sqlite, "google_discovery_source")) return;
493+ const rows = sqlite
494+ .prepare("SELECT scope_id, id, name, config FROM google_discovery_source")
495+ .all() as ReadonlyArray<GoogleRow>;
496+ if (rows.length === 0) return;
497+
498+ const updateSource = sqlite.prepare(
499+ "UPDATE google_discovery_source SET config = ?, updated_at = ? WHERE scope_id = ? AND id = ?",
500+ );
501+
502+ for (const row of rows) {
503+ let config: Record<string, unknown> = {};
504+ try {
505+ const parsed = JSON.parse(row.config) as unknown;
506+ if (!isRecord(parsed)) continue;
507+ config = parsed;
508+ } catch {
509+ continue;
510+ }
511+ const auth = config.auth;
512+ if (!isRecord(auth) || auth.kind !== "oauth2") continue;
513+
514+ if (Option.isSome(decodeGoogleCurrent(auth))) continue;
515+
516+ const legacyOption = decodeGoogleLegacy(auth);
517+ if (Option.isNone(legacyOption)) continue;
518+ const legacy = legacyOption.value;
519+
520+ const connectionId = `google-discovery-oauth2-${randomUUID()}`;
521+ const providerState = {
522+ clientIdSecretId: legacy.clientIdSecretId,
523+ clientSecretSecretId: legacy.clientSecretSecretId,
524+ scopes: legacy.scopes,
525+ };
526+ const authPointer = {
527+ kind: "oauth2" as const,
528+ connectionId,
529+ clientIdSecretId: legacy.clientIdSecretId,
530+ clientSecretSecretId: legacy.clientSecretSecretId,
531+ scopes: legacy.scopes,
532+ };
533+ const nextConfig = { ...config, auth: authPointer };
534+
535+ const secretIds = [legacy.accessTokenSecretId];
536+ const secretNames = [`Connection ${connectionId} access token`];
537+ if (legacy.refreshTokenSecretId) {
538+ secretIds.push(legacy.refreshTokenSecretId);
539+ secretNames.push(`Connection ${connectionId} refresh token`);
540+ }
541+
542+ const txn = sqlite.transaction(() => {
543+ insertConnectionRow(sqlite, {
544+ id: connectionId,
545+ scopeId: row.scope_id,
546+ provider: "google-discovery:oauth2",
547+ identityLabel: row.name,
548+ accessTokenSecretId: legacy.accessTokenSecretId,
549+ refreshTokenSecretId: legacy.refreshTokenSecretId,
550+ expiresAt: legacy.expiresAt,
551+ scope: legacy.scope,
552+ providerState,
553+ });
554+ const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);
555+ if (err) throw new Error(err);
556+ updateSource.run(JSON.stringify(nextConfig), Date.now(), row.scope_id, row.id);
282557 });
283558 try {
284559 txn();
285560 console.log(
286− `[migrate-connections] ${row.scope_id}/${row.id} -> ${connectionId}`,
561+ `[migrate-connections] google-discovery ${row.scope_id}/${row.id} -> ${connectionId}`,
287562 );
288563 } catch (err) {
289564 console.warn(
290− `[migrate-connections] fail ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`,
565+ `[migrate-connections] fail google-discovery ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`,
291566 );
292567 }
293568 }
294569 };
570+
571+// ---------------------------------------------------------------------------
572+// Umbrella
573+// ---------------------------------------------------------------------------
574+
575+/**
576+ * Scan openapi_source, mcp_source, and google_discovery_source; migrate
577+ * any row still on its plugin's legacy inline-OAuth shape to a fresh
578+ * Connection row + pointer. Idempotent — rows already on the current
579+ * shape are skipped. Logs one line per migrated row.
580+ */
581+export const migrateLegacyConnections = async (sqlite: Database): Promise<void> => {
582+ if (!connectionsReady(sqlite)) return;
583+ await migrateOpenApi(sqlite);
584+ migrateMcp(sqlite);
585+ migrateGoogleDiscovery(sqlite);
586+};
packages/plugins/mcp/src/sdk/index.ts+2−0
@@ -20,3 +20,5 @@ export { · 19 unmodified lines
2020 type McpSchema,
2121 type McpStoredSource,
2222 } from "./binding-store";
23+
24+export { McpConnectionAuth } from "./types";
packages/plugins/mcp/src/sdk/plugin.ts+13−1
@@ -304,7 +304,11 @@ const MCP_OAUTH2_PROVIDER_KEY = "mcp:oauth2" as const; · 303 unmodified lines
304304 const OAuth2ProviderState = Schema.Struct({
305305 endpoint: Schema.String,
306306 tokenType: Schema.String,
307− clientInformation: McpJsonObject,
307+ // Nullable to accommodate early-onboarded sources where DCR client
308+ // registration was never persisted back onto the source. Refresh fails
309+ // fast on null and surfaces a "re-sign-in required" error, which the UI
310+ // turns into a Reconnect prompt.
311+ clientInformation: Schema.NullOr(McpJsonObject),
308312 authorizationServerUrl: Schema.NullOr(Schema.String),
309313 authorizationServerMetadata: Schema.NullOr(McpJsonObject),
310314 resourceMetadataUrl: Schema.NullOr(Schema.String),
@@ -1290,6 +1294,14 @@ export const mcpPlugin = definePlugin( · 979 unmodified lines
12901294 }),
12911295 });
12921296
1297+ if (state.clientInformation === null) {
1298+ return yield* new ConnectionRefreshError({
1299+ connectionId: input.connectionId,
1300+ message:
1301+ "mcp:oauth2 connection has no clientInformation — re-sign-in required (legacy row; DCR was never persisted)",
1302+ });
1303+ }
1304+
12931305 const authServerUrl =
12941306 state.authorizationServerUrl ??
12951307 new URL("/", state.endpoint).toString();