From 319be994b6d51415b60e03b488754d2fcfb09869 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Jun 2026 16:57:25 -0500 Subject: [PATCH] fix(core): migrate bare v1-shaped mcp config blocks isV1 only triggered on a fixed set of top-level keys, so a config with just $schema and a v1-shaped mcp block (servers directly under mcp, with enabled) was parsed as v2 and silently produced zero servers. Detect the v1 mcp shape (no servers wrapper, entries with a type) so these files migrate correctly. --- packages/core/src/v1/config/migrate.ts | 14 +++++++++++++- packages/core/test/config/config.test.ts | 12 ++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 5f62858e3c..2a9e1c7383 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -29,7 +29,19 @@ const keys = new Set([ export function isV1(input: unknown) { if (typeof input !== "object" || input === null || Array.isArray(input)) return false - return Object.keys(input).some((key) => keys.has(key)) + const record = input as Record + if (Object.keys(record).some((key) => keys.has(key))) return true + // `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under + // `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`) + // counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers. + const mcp = record.mcp + return ( + typeof mcp === "object" && + mcp !== null && + !Array.isArray(mcp) && + !("servers" in mcp) && + Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server) + ) } export function migrate(info: typeof ConfigV1.Info.Type) { diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 0f61c6c1df..f16312b763 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -76,6 +76,18 @@ describe("Config", () => { }), ) + it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () => + Effect.sync(() => { + // V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates. + expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true) + expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true) + // V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated. + expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false) + expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false) + expect(ConfigMigrateV1.isV1({ mcp: { timeout: { request: 1000 } } })).toBe(false) + }), + ) + it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () => Effect.sync(() => { FastCheck.assert(