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
18import { Option, Schema } from "effect";
19import postgres from "postgres";
20
21import { McpConnectionAuth } from "@executor/plugin-mcp";
22
23const APPLY = process.argv.includes("--apply");
24const 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
32const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
33
34const 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});
51type LegacyMcpOAuth2 = typeof LegacyMcpOAuth2.Type;
52
53const decodeCurrentAuth = Schema.decodeUnknownOption(McpConnectionAuth);
54const decodeLegacy = Schema.decodeUnknownOption(LegacyMcpOAuth2);
55
56const isRecord = (v: unknown): v is Record<string, unknown> =>
57 typeof v === "object" && v !== null && !Array.isArray(v);
58
59// ---------------------------------------------------------------------------
60// Row classification
61// ---------------------------------------------------------------------------
62
63type Row = {
64 scope_id: string;
65 id: string;
66 name: string;
67 config: unknown;
68};
69
70type 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
78const 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
111type SecretRow = {
112 id: string;
113 scope_id: string;
114 owned_by_connection_id: string | null;
115};
116
117const 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
329main().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
14 readLegacySecrets,14 readLegacySecrets,
15 type LegacySecret,15 type LegacySecret,
16} from "./db-upgrade";16} from "./db-upgrade";
17import { migrateOpenApiOAuthConnections } from "./migrate-connections";17import { migrateLegacyConnections } from "./migrate-connections";
18 18
19import {19import {
20 Scope,20 Scope,
@@ -137,9 +137,10 @@ const createLocalExecutorLayer = () => { · 116 unmodified lines
137 if (legacySecrets.length > 0) {137 if (legacySecrets.length > 0) {
138 importLegacySecrets(sqlite, scopeId, legacySecrets);138 importLegacySecrets(sqlite, scopeId, legacySecrets);
139 }139 }
140 // Upgrade pre-connection OpenAPI OAuth rows onto the new Connection140 // Upgrade pre-connection openapi/mcp/google-discovery rows onto the
141 // pointer shape. Idempotent + no-op when there's nothing to migrate.141 // new Connection pointer shape. Idempotent + no-op when there's
142 yield* Effect.promise(() => migrateOpenApiOAuthConnections(sqlite));142 // nothing to migrate.
143 yield* Effect.promise(() => migrateLegacyConnections(sqlite));
143 const configPath = resolveConfigPath(cwd);144 const configPath = resolveConfigPath(cwd);
144 const configFile = makeFileConfigSink({145 const configFile = makeFileConfigSink({
145 path: configPath,146 path: configPath,
apps/local/src/server/migrate-connections.ts+425−133
@@ -1,16 +1,16 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// OpenAPI OAuth legacy → Connection backfill (local)2// OAuth legacy → Connection backfill (local)
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4//4//
5// Runs at boot time right after `migrate()`. For every `openapi_source`5// Runs at boot time right after `migrate()`. For every row still on the
6// row still on the pre-refactor OAuth2 shape, mints a Connection row,6// pre-refactor inline-OAuth shape (openapi_source, mcp_source,
7// re-parents the referenced secret(s) to it, and rewrites both the7// google_discovery_source), mints a Connection row, re-parents the
8// top-level `oauth2` column and the nested `invocation_config.oauth2`8// referenced secret(s) to it, and rewrites the source's stored auth to
9// copy to the new pointer shape.9// the new pointer shape.
10//10//
11// Self-contained: the only plugin imports are current-shape parsing11// Self-contained: the only plugin imports are current-shape parsing
12// helpers. The pre-refactor shape is defined inline — this file is the12// helpers. Each legacy shape is defined inline — this file is the last
13// last place in the codebase that still needs to know about it.13// place in the codebase that still needs to know about them.
14 14
15import { Database } from "bun:sqlite";15import { Database } from "bun:sqlite";
16import { randomUUID } from "node:crypto";16import { randomUUID } from "node:crypto";
@@ -21,10 +21,150 @@ import { · 4 unmodified lines
21 resolveSpecText,21 resolveSpecText,
22 OAuth2Auth,22 OAuth2Auth,
23} from "@executor/plugin-openapi";23} from "@executor/plugin-openapi";
24import { McpConnectionAuth } from "@executor/plugin-mcp";
25
26// ---------------------------------------------------------------------------
27// Shared helpers
28// ---------------------------------------------------------------------------
29
30const isRecord = (v: unknown): v is Record<string, unknown> =>
31 typeof v === "object" && v !== null && !Array.isArray(v);
32const isString = (v: unknown): v is string => typeof v === "string";
33
34const 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. */
38const 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
49const 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
56type 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. */
61const 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
122const 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// ---------------------------------------------------------------------------
24 164
25const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials");165const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials");
26 166
27class LegacyOAuth2Auth extends Schema.Class<LegacyOAuth2Auth>("LegacyOAuth2Auth")({167class LegacyOpenApiOAuth2 extends Schema.Class<LegacyOpenApiOAuth2>("LegacyOpenApiOAuth2")({
28 kind: Schema.Literal("oauth2"),168 kind: Schema.Literal("oauth2"),
29 securitySchemeName: Schema.String,169 securitySchemeName: Schema.String,
30 flow: OAuth2Flow,170 flow: OAuth2Flow,
@@ -39,12 +179,8 @@ class LegacyOAuth2Auth extends Schema.Class<LegacyOAuth2Auth>("LegacyOAuth2Auth" · 8 unmodified lines
39 scopes: Schema.Array(Schema.String),179 scopes: Schema.Array(Schema.String),
40}) {}180}) {}
41 181
42const decodeCurrent = Schema.decodeUnknownOption(OAuth2Auth);182const decodeOpenApiCurrent = Schema.decodeUnknownOption(OAuth2Auth);
43const decodeLegacy = Schema.decodeUnknownOption(LegacyOAuth2Auth);183const decodeOpenApiLegacy = Schema.decodeUnknownOption(LegacyOpenApiOAuth2);
44
45const isRecord = (v: unknown): v is Record<string, unknown> =>
46 typeof v === "object" && v !== null && !Array.isArray(v);
47const isString = (v: unknown): v is string => typeof v === "string";
48 184
49const extractAuthorizationUrl = async (185const extractAuthorizationUrl = async (
50 rawSpec: string,186 rawSpec: string,
@@ -78,7 +214,7 @@ const extractAuthorizationUrl = async ( · 27 unmodified lines
78 : null;214 : null;
79};215};
80 216
81type Row = {217type OpenApiRow = {
82 scope_id: string;218 scope_id: string;
83 id: string;219 id: string;
84 name: string;220 name: string;
@@ -87,56 +223,15 @@ type Row = { · 2 unmodified lines
87 oauth2: string | null;223 oauth2: string | null;
88};224};
89 225
90/**226const migrateOpenApi = async (sqlite: Database): Promise<void> => {
91 * Scan `openapi_source`, migrate any row still on the legacy OAuth2 shape227 if (!tableExists(sqlite, "openapi_source")) return;
92 * to a fresh Connection row + pointer. Idempotent — rows already on the
93 * current shape are skipped. Logs one line per migrated row.
94 */
95export 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
110 const rows = sqlite228 const rows = sqlite
111 .prepare(229 .prepare(
112 "SELECT scope_id, id, name, spec, invocation_config, oauth2 FROM openapi_source",230 "SELECT scope_id, id, name, spec, invocation_config, oauth2 FROM openapi_source",
113 )231 )
114 .all() as ReadonlyArray<Row>;232 .all() as ReadonlyArray<OpenApiRow>;
115 if (rows.length === 0) return;233 if (rows.length === 0) return;
116 234
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 );
140 const updateSource = sqlite.prepare(235 const updateSource = sqlite.prepare(
141 "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?",236 "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?",
142 );237 );
@@ -161,9 +256,9 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 18 unmodified lines
161 }256 }
162 const primary = invocation.oauth2 ?? oauth2Col;257 const primary = invocation.oauth2 ?? oauth2Col;
163 if (primary == null) continue;258 if (primary == null) continue;
164 if (Option.isSome(decodeCurrent(primary))) continue;259 if (Option.isSome(decodeOpenApiCurrent(primary))) continue;
165 260
166 const legacyOption = decodeLegacy(primary);261 const legacyOption = decodeOpenApiLegacy(primary);
167 if (Option.isNone(legacyOption)) continue;262 if (Option.isNone(legacyOption)) continue;
168 const legacy = legacyOption.value;263 const legacy = legacyOption.value;
169 264
@@ -174,7 +269,7 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 4 unmodified lines
174 );269 );
175 if (legacy.flow === "authorizationCode" && authorizationUrl === null) {270 if (legacy.flow === "authorizationCode" && authorizationUrl === null) {
176 console.warn(271 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`,
178 );273 );
179 continue;274 continue;
180 }275 }
@@ -200,95 +295,292 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 19 unmodified lines
200 };295 };
201 296
202 const secretIds = [legacy.accessTokenSecretId];297 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 }
204 303
205 const secretRows = secretIds.map(304 const txn = sqlite.transaction(() => {
206 (sid) =>305 insertConnectionRow(sqlite, {
207 selectSecret.get(row.scope_id, sid) as306 id: connectionId,
208 | { id: string; owned_by_connection_id: string | null }307 scopeId: row.scope_id,
209 | undefined,308 provider: "openapi:oauth2",
210 );309 identityLabel: row.name,
211 const alreadyOwned = secretRows310 accessTokenSecretId: legacy.accessTokenSecretId,
212 .filter((s): s is { id: string; owned_by_connection_id: string | null } => !!s)311 refreshTokenSecretId: legacy.refreshTokenSecretId,
213 .filter(312 expiresAt: legacy.expiresAt,
214 (s) =>313 scope: legacy.scope,
215 s.owned_by_connection_id !== null &&314 providerState,
216 s.owned_by_connection_id !== connectionId,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}`,
217 );330 );
218 if (alreadyOwned.length > 0) {331 } catch (err) {
219 console.warn(332 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)}`,
221 );334 );
335 }
336 }
337};
338
339// ---------------------------------------------------------------------------
340// MCP — legacy shape
341// ---------------------------------------------------------------------------
342
343const 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
361const decodeMcpCurrent = Schema.decodeUnknownOption(McpConnectionAuth);
362const decodeMcpLegacy = Schema.decodeUnknownOption(LegacyMcpOAuth2);
363
364type McpRow = {
365 scope_id: string;
366 id: string;
367 name: string;
368 config: string;
369};
370
371const 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 {
222 continue;389 continue;
223 }390 }
224 // Early-onboarded OpenAPI OAuth tokens never got a `secret` routing391 if (config.transport !== "remote") continue;
225 // row — pre-refactor `secretsGet` resolved them via provider392 const auth = config.auth;
226 // enumeration. Pick the provider already in use at this scope (or393 if (!isRecord(auth) || auth.kind !== "oauth2") continue;
227 // fall back to keychain) so the new id-indexed fast path resolves;394
228 // if we guess wrong the SDK's enumerate-fallback still works.395 if (Option.isSome(decodeMcpCurrent(auth))) continue;
229 const missingIndexes = secretIds396
230 .map((_, i) => i)397 const legacyOption = decodeMcpLegacy(auth);
231 .filter((i) => secretRows[i] === undefined);398 if (Option.isNone(legacyOption)) continue;
232 let fallbackProvider: string | null = null;399 const legacy = legacyOption.value;
233 if (missingIndexes.length > 0) {400
234 const existing = selectAnySecretProvider.get(row.scope_id) as401 const endpoint = typeof config.endpoint === "string" ? config.endpoint : null;
235 | { provider: string }402 if (!endpoint) {
236 | undefined;403 console.warn(
237 fallbackProvider = existing?.provider ?? "keychain";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`);
238 }426 }
239 427
240 const now = Date.now();
241 const txn = sqlite.transaction(() => {428 const txn = sqlite.transaction(() => {
242 insertConnection.run(429 insertConnectionRow(sqlite, {
243 connectionId,430 id: connectionId,
244 row.scope_id,431 scopeId: row.scope_id,
245 "openapi:oauth2",432 provider: "mcp:oauth2",
246 "user",433 identityLabel: row.name,
247 row.name,434 accessTokenSecretId: legacy.accessTokenSecretId,
248 legacy.accessTokenSecretId,435 refreshTokenSecretId: legacy.refreshTokenSecretId,
249 legacy.refreshTokenSecretId,436 expiresAt: legacy.expiresAt,
250 legacy.expiresAt,437 scope: legacy.scope,
251 legacy.scope,438 providerState,
252 JSON.stringify(providerState),439 });
253 now,440 const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames);
254 now,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}`,
255 );448 );
256 for (let i = 0; i < secretIds.length; i++) {449 } catch (err) {
257 const sid = secretIds[i]!;450 console.warn(
258 if (secretRows[i] === undefined) {451 `[migrate-connections] fail mcp ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`,
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,
281 );452 );
453 }
454 }
455};
456
457// ---------------------------------------------------------------------------
458// google-discovery — legacy shape
459// ---------------------------------------------------------------------------
460
461const 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
473const 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
481const decodeGoogleCurrent = Schema.decodeUnknownOption(CurrentGoogleDiscoveryOAuth2);
482const decodeGoogleLegacy = Schema.decodeUnknownOption(LegacyGoogleDiscoveryOAuth2);
483
484type GoogleRow = {
485 scope_id: string;
486 id: string;
487 name: string;
488 config: string;
489};
490
491const 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);
282 });557 });
283 try {558 try {
284 txn();559 txn();
285 console.log(560 console.log(
286 `[migrate-connections] ${row.scope_id}/${row.id} -> ${connectionId}`,561 `[migrate-connections] google-discovery ${row.scope_id}/${row.id} -> ${connectionId}`,
287 );562 );
288 } catch (err) {563 } catch (err) {
289 console.warn(564 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)}`,
291 );566 );
292 }567 }
293 }568 }
294};569};
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 */
581export 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
20 type McpSchema,20 type McpSchema,
21 type McpStoredSource,21 type McpStoredSource,
22} from "./binding-store";22} from "./binding-store";
23
24export { 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
304const OAuth2ProviderState = Schema.Struct({304const OAuth2ProviderState = Schema.Struct({
305 endpoint: Schema.String,305 endpoint: Schema.String,
306 tokenType: Schema.String,306 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),
308 authorizationServerUrl: Schema.NullOr(Schema.String),312 authorizationServerUrl: Schema.NullOr(Schema.String),
309 authorizationServerMetadata: Schema.NullOr(McpJsonObject),313 authorizationServerMetadata: Schema.NullOr(McpJsonObject),
310 resourceMetadataUrl: Schema.NullOr(Schema.String),314 resourceMetadataUrl: Schema.NullOr(Schema.String),
@@ -1290,6 +1294,14 @@ export const mcpPlugin = definePlugin( · 979 unmodified lines
1290 }),1294 }),
1291 });1295 });
1292 1296
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
1293 const authServerUrl =1305 const authServerUrl =
1294 state.authorizationServerUrl ??1306 state.authorizationServerUrl ??
1295 new URL("/", state.endpoint).toString();1307 new URL("/", state.endpoint).toString();