fix(setup): name structural problems in client configurations (#2154)

A client configuration that parses but whose root or MCP server table is
not an object (for example {"mcpServers": null} or []) made setup print a
raw Zod issue dump and made uninstall claim the file was not valid JSON.
parseClientConfiguration now raises a ClientConfigurationShapeError naming
the root or the servers key, which setup reports as the malformed detail
and uninstall reports instead of a syntax error.

Fixes #2152

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-12 06:53:55 -07:00 committed by GitHub
parent 90e37f4152
commit 51367cae6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 61 additions and 5 deletions

View File

@ -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<string, unknown> => {
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<string, unknown>;
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(

View File

@ -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.`,
),
);
}

View File

@ -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);