diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 65b4ba8de..27b5393d2 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -128,6 +128,20 @@ const parseDocument = ( return objectSchema.parse(document); }; +/** A parsed configuration whose structure, not its syntax, cannot hold MCP servers. */ +export class ClientConfigurationShapeError extends TypeError {} + +const requireObject = ( + value: unknown, + description: string, +): Record => { + const parsed = objectSchema.safeParse(value); + if (parsed.success) return parsed.data; + throw new ClientConfigurationShapeError(`${description} must be an object`, { + cause: parsed.error, + }); +}; + /** Members of OpenCode V2's `mcp` object that are not server names. */ const OPENCODE_V2_MCP_SETTINGS = new Set(["servers", "timeout"]); @@ -140,10 +154,21 @@ export const parseClientConfiguration = ( throw new TypeError("client does not have a supported MCP config format"); // DeepSeek Harness stores a Cordis patch list, not a server table. if (format === "dsh") return parseDshCordisDocument(text); - const document = parseDocument(text, format); + let document: Record; + try { + document = parseDocument(text, format); + } catch (cause: unknown) { + if (cause instanceof z.ZodError) + throw new ClientConfigurationShapeError( + "The configuration root must be an object", + { cause }, + ); + throw cause; + } const serversKey = clientConfigurationServersKey(format); const value = document[serversKey]; - const servers = value === undefined ? {} : objectSchema.parse(value); + const servers = + value === undefined ? {} : requireObject(value, `"${serversKey}"`); const native = servers.servers; // A V1 server named `servers` has a string `type` discriminator; the V2 // table may instead hold a server object named `type`. @@ -164,7 +189,7 @@ export const parseClientConfiguration = ( }; return { document, - servers: objectSchema.parse(native), + servers: requireObject(native, `"${serversKey}.servers"`), serversPath: [serversKey, "servers"], dialect: "opencode_v2", legacyServers: Object.fromEntries( diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 6822460bb..24ff88ea6 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -1,4 +1,5 @@ import { + ClientConfigurationShapeError, parseClientConfiguration, serializeClientConfiguration, clientServerPath, @@ -279,12 +280,13 @@ const readClientConfiguration = async ( const parsed = parseClientConfiguration(original, client.format); return { kind: "configuration", transactionPath, original, parsed }; } catch (cause: unknown) { - void cause; return itemRead( item( client.name, "failed", - `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`, + cause instanceof ClientConfigurationShapeError + ? `Configuration was not changed: ${cause.message}. Repair it, then rerun uninstall.` + : `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`, ), ); } diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index db0b517e7..6ee7543e9 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -21,6 +21,7 @@ import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { readConfigurationFile } from "../../../src/application/ClientConfigurationFiles.js"; import { detectClients, systemSetupHost, @@ -1593,6 +1594,34 @@ describe("uninstall configuration preflight", () => { expect(await readFile(config, "utf8")).toBe("not-json"); }); + it.each([ + ['{"mcpServers": null}', '"mcpServers" must be an object'], + ["[]", "The configuration root must be an object"], + ])( + "names the structural problem in valid JSON %s", + async (content, problem) => { + const home = await createTestTempDirectory("rea-uninstall-shape-"); + roots.push(home); + const config = join(home, ".cursor/mcp.json"); + await mkdir(dirname(config), { recursive: true }); + await writeFile(config, content); + expect(await readConfigurationFile(config, "json")).toMatchObject({ + ok: false, + error: { + kind: "malformed", + detail: `Configuration ${config} is malformed: ${problem}`, + }, + }); + const result = await runUninstall(false, systemUninstallHost(home)); + expect(result.items).toContainEqual({ + name: "cursor", + status: "failed", + detail: `Configuration was not changed: ${problem}. Repair it, then rerun uninstall.`, + }); + expect(await readFile(config, "utf8")).toBe(content); + }, + ); + it("stops before any removal when one client configuration is malformed", async () => { const home = await createTestTempDirectory("rea-uninstall-stop-"); roots.push(home);