fix(coding-agent): accept mcp.json files with a UTF-8 BOM

closes #11191
This commit is contained in:
Mario Zechner 2026-10-21 00:14:05 +02:00
parent 22dc6c26f3
commit bdd9f0399d
3 changed files with 27 additions and 2 deletions

View file

@ -21,6 +21,7 @@
- Fixed pasting in the shell hanging after copying text in pi and suspending it with Ctrl+Z on Linux: the background `wl-copy`/`xclip`/`xsel` process that keeps serving the clipboard was suspended along with pi ([#11179](https://github.com/earendil-works/pi/issues/11179))
- Fixed codemode scripts failing immediately when both codemode workers are busy, for example with parallel codemode calls or in-process subagents. Scripts now wait up to 60 seconds for a free worker, and `PI_CODEMODE_MAX_WORKERS` raises the limit of two workers ([#11184](https://github.com/earendil-works/pi/issues/11184))
- Fixed extensions failing to load when importing `@earendil-works/pi-ai/models` (e.g. for `clampThinkingLevel`) by exposing the subpath through the extension loader aliases and virtual modules ([#11156](https://github.com/earendil-works/pi/issues/11156))
- Fixed `mcp.json` files that start with a UTF-8 BOM failing to parse, which skipped all their MCP servers and made `pi mcp add`/`pi mcp remove` fail ([#11191](https://github.com/earendil-works/pi/issues/11191))
## [1.3.0] - 2026-10-15

View file

@ -38,6 +38,7 @@ import {
mcpNamespace,
validateMcpServerConfig,
} from "../../core/mcp-servers.ts";
import { stripBom } from "../../utils/text.ts";
export type {
McpExposure,
@ -93,7 +94,7 @@ function readConfigFile(path: string, scope: "global" | "project", state: McpCon
if (!existsSync(path)) return;
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
parsed = JSON.parse(stripBom(readFileSync(path, "utf8")));
} catch (error) {
errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
return;
@ -229,7 +230,7 @@ function editMcpServers(
path: string,
edit: (servers: Record<string, unknown> | undefined, parsed: Record<string, unknown>) => boolean,
): void {
const text = existsSync(path) ? readFileSync(path, "utf8") : undefined;
const text = existsSync(path) ? stripBom(readFileSync(path, "utf8")) : undefined;
const parsed: unknown = text === undefined ? {} : JSON.parse(text);
if (!isRecord(parsed) || (parsed.mcpServers !== undefined && !isRecord(parsed.mcpServers))) {
throw new Error(`${path}: expected an object with an "mcpServers" object`);

View file

@ -16,6 +16,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { InMemoryAuthStorageBackend } from "../src/core/auth-storage.ts";
import { truncateMiddle } from "../src/core/tools/truncate.ts";
import {
addMcpServerConfig,
getMcpToolExposure,
loadMcpConfig,
type McpServerEntry,
@ -115,6 +116,28 @@ describe("MCP config", () => {
expect(JSON.parse(readFileSync(project, "utf8")).mcpServers.tools).toEqual({ enabled: true });
});
// Regression: #11191.
it("reads and edits mcp.json files that start with a UTF-8 BOM", () => {
const paths = setup({}, {});
const global = join(paths.agentDir, "mcp.json");
const project = join(paths.cwd, ".pi", "mcp.json");
writeFileSync(global, '\uFEFF{"mcpServers":{"demo":{"command":"echo","enabled":false}}}\n');
writeFileSync(project, '\uFEFF{"mcpServers":{"local":{"command":"local-cmd"}}}\n');
const loaded = loadMcpConfig({ ...paths, projectTrusted: true });
expect(loaded.errors).toEqual([]);
expect(loaded.servers.map((server) => [server.name, server.scope])).toEqual([
["demo", "global"],
["local", "project"],
]);
expect(addMcpServerConfig(global, "other", { command: "other-cmd" })).toBe(false);
expect(JSON.parse(readFileSync(global, "utf8")).mcpServers).toEqual({
demo: { command: "echo", enabled: false },
other: { command: "other-cmd" },
});
});
// Regression: #10239.
it("rejects server names that differ only in - and _", () => {
const paths = setup({ mcpServers: { "work-files": { command: "a" }, work_files: { command: "b" } } }, {});