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:
parent
90e37f4152
commit
51367cae6f
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue