diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b15bdbb56..8945d4bab 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/extensions/mcp/config.ts b/packages/coding-agent/src/extensions/mcp/config.ts index 3dae93bd7..d06fddfd8 100644 --- a/packages/coding-agent/src/extensions/mcp/config.ts +++ b/packages/coding-agent/src/extensions/mcp/config.ts @@ -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 | undefined, parsed: Record) => 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`); diff --git a/packages/coding-agent/test/mcp-extension.test.ts b/packages/coding-agent/test/mcp-extension.test.ts index 05a120c9b..0948434fd 100644 --- a/packages/coding-agent/test/mcp-extension.test.ts +++ b/packages/coding-agent/test/mcp-extension.test.ts @@ -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" } } }, {});