From 1387af7b427c5328eba734715f61b97b0b822688 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 2 Oct 2026 18:34:49 +0200 Subject: [PATCH] feat(coding-agent): allow project mcp.json to override enabled and exposure of global servers A .pi/mcp.json entry without command, url, or type overrides only enabled, exposure, and toolExposure of the global server with the same name. /mcp can enable or disable a global server for the trusted project, and pi mcp list shows the override. closes #10277 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/mcp.md | 12 +++- .../coding-agent/src/extensions/mcp/cli.ts | 4 ++ .../coding-agent/src/extensions/mcp/config.ts | 61 ++++++++++++++--- .../coding-agent/src/extensions/mcp/index.ts | 66 ++++++++++++++----- .../coding-agent/test/mcp-extension.test.ts | 36 +++++++++- 6 files changed, 152 insertions(+), 28 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ae540b7f4..c1d09165e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Added a copy key (`app.message.copy`, default `ctrl+x`) to OAuth sign-in screens in `/login`, `/mcp`, and `/mcp login`, which copies the sign-in URL when the browser cannot be opened or the wrapped link cannot be selected. - Added `oauth.clientRegistration: "cimd"` for MCP servers, which identifies pi with its Client ID Metadata Document on pi.dev instead of dynamic client registration, so authorization servers can allow pi by URL ([#10302](https://github.com/earendil-works/pi/issues/10302)) +- Added project overrides for user-level MCP servers: a `.pi/mcp.json` entry without `command` or `url` sets only `enabled`, `exposure`, and `toolExposure` of the user-level server, and `/mcp` can enable or disable a server for the current project ([#10277](https://github.com/earendil-works/pi/issues/10277)) - Added Cloudflare's Clef and Clef Flash classifier models to `cloudflare-workers-ai`, usable from codemode scripts and extensions ([#10316](https://github.com/earendil-works/pi/pull/10316) by [@ndisidore](https://github.com/ndisidore), [#10322](https://github.com/earendil-works/pi/pull/10322) by [@RealAlexandreAI](https://github.com/RealAlexandreAI)) ### Changed diff --git a/packages/coding-agent/docs/mcp.md b/packages/coding-agent/docs/mcp.md index ea39e3145..0d0116141 100644 --- a/packages/coding-agent/docs/mcp.md +++ b/packages/coding-agent/docs/mcp.md @@ -31,6 +31,16 @@ Use `/mcp` inside an interactive session to inspect connections, sign in, reconn Pi reads user-level servers from `~/.pi/agent/mcp.json` and project servers from `.pi/mcp.json`. Project configuration is read only after [project trust](security.md#understand-project-trust) is granted. A project entry replaces a user-level entry with the same name. +A project entry without `command`, `url`, or `type` overrides only `enabled`, `exposure`, and `toolExposure` of the user-level server with the same name and keeps the rest, including `env`, `headers`, and `auth`. For example, this turns off a user-level server in one project: + +```json +{ + "mcpServers": { + "internal-tools": { "enabled": false } + } +} +``` + The format matches other MCP clients: ```json @@ -77,7 +87,7 @@ Keep personal servers and servers with credentials in the user-level file. Use t `/mcp` lists configured servers with their state, tool count, exposure, and configuration source. Servers that need attention appear first. Select a server to inspect its tools and connection details, reconnect, sign in or out, change exposure, or enable and disable it. -Exposure and enabled-state changes are saved to the file that defines the server without replacing unrelated content. Disabled servers remain listed. Outside the interactive TUI, `/mcp` prints server status; `/mcp login `, `/mcp logout `, and `/mcp reconnect ` perform those actions directly. +Exposure and enabled-state changes are saved to the file that defines the server without replacing unrelated content. In a trusted project, "Enable in this project" and "Disable in this project" add a project override for a user-level server; later changes to that server are saved to the override. Disabled servers remain listed. Outside the interactive TUI, `/mcp` prints server status; `/mcp login `, `/mcp logout `, and `/mcp reconnect ` perform those actions directly. Shell commands work without a session: `pi mcp add`, `pi mcp remove`, `pi mcp list`, `pi mcp login`, and `pi mcp logout`. Shell commands do not load extensions. diff --git a/packages/coding-agent/src/extensions/mcp/cli.ts b/packages/coding-agent/src/extensions/mcp/cli.ts index 64ce281f2..9c7c38f63 100644 --- a/packages/coding-agent/src/extensions/mcp/cli.ts +++ b/packages/coding-agent/src/extensions/mcp/cli.ts @@ -92,6 +92,8 @@ interface ServerReport { name: string; scope: string; source: string; + /** Project `mcp.json` that overrides `enabled`, `exposure`, or `toolExposure` of this global server. */ + override?: string; enabled: boolean; exposure: string; transport: string; @@ -443,6 +445,7 @@ async function list( name: entry.name, scope: entry.scope ?? "global", source: entry.source, + ...(entry.override ? { override: entry.override } : {}), enabled: entry.config.enabled !== false, exposure: entry.config.exposure ?? "codemode", transport: describeTransport(entry), @@ -496,6 +499,7 @@ async function list( : report.state; log(`${report.name}: ${state} (${report.exposure}, ${report.scope})`); log(` ${report.transport}`); + if (report.override) log(` project override: ${report.override}`); if (report.state === "needs-auth") log(` sign in with: ${APP_NAME} mcp login ${report.name}`); if (report.tools.length > 0) { const tools = report.tools.map((tool) => { diff --git a/packages/coding-agent/src/extensions/mcp/config.ts b/packages/coding-agent/src/extensions/mcp/config.ts index c2b7efb8e..3dae93bd7 100644 --- a/packages/coding-agent/src/extensions/mcp/config.ts +++ b/packages/coding-agent/src/extensions/mcp/config.ts @@ -6,6 +6,11 @@ * existing configurations can be copied over. Project entries replace global entries with the * same name. * + * A project entry without `command`, `url`, or `type` overrides only `enabled`, `exposure`, and + * `toolExposure` of the global server with the same name, for example to turn it off in one project: + * `{ "mcpServers": { "internal-tools": { "enabled": false } } }`. The rest of the global entry is kept, + * including credentials the project could not set itself. + * * ```json * { * "mcpServers": { @@ -53,6 +58,8 @@ export interface McpServerEntry { * `pi.registerMcpServer()`. Changes to extension servers are not saved. */ scope?: "global" | "project" | "extension"; + /** Project `mcp.json` with an override of this global server's `enabled`, `exposure`, or `toolExposure`. */ + override?: string; } export interface LoadedMcpConfig { @@ -60,6 +67,15 @@ export interface LoadedMcpConfig { /** Activate the codemode tool when `codemode` servers connect. Default: true. */ autoEnableCodemode?: boolean; errors: string[]; + /** The project `mcp.json` when the project is trusted, where `/mcp` saves project overrides. */ + projectConfig?: string; +} + +const OVERRIDE_KEYS = ["enabled", "exposure", "toolExposure"]; + +/** Whether an entry overrides a server defined elsewhere instead of defining one. */ +function isOverride(value: Record): boolean { + return value.command === undefined && value.url === undefined && value.type === undefined; } function isRecord(value: unknown): value is Record { @@ -89,6 +105,20 @@ function readConfigFile(path: string, scope: "global" | "project", state: McpCon if (typeof parsed.autoEnableCodemode === "boolean") state.autoEnableCodemode = parsed.autoEnableCodemode; else if (parsed.autoEnableCodemode !== undefined) errors.push(`${path}: autoEnableCodemode must be a boolean`); for (const [name, value] of Object.entries(parsed.mcpServers ?? {})) { + if (scope === "project" && isRecord(value) && isOverride(value)) { + const base = servers.get(name); + const extra = Object.keys(value).filter((key) => !OVERRIDE_KEYS.includes(key)); + if (!base) { + errors.push(`${path}: server "${name}" needs "command" or "url", or a global server to override`); + } else if (extra.length > 0) { + errors.push(`${path}: server "${name}": an override can only set ${OVERRIDE_KEYS.join(", ")}`); + } else { + const config = validateMcpServerConfig(name, { ...base.config, ...value }); + if (typeof config === "string") errors.push(`${path}: ${config}`); + else servers.set(name, { ...base, config, override: path }); + } + continue; + } const config = validateMcpServerConfig(name, value); if (typeof config === "string") { errors.push(`${path}: ${config}`); @@ -115,11 +145,13 @@ function readConfigFile(path: string, scope: "global" | "project", state: McpCon export function loadMcpConfig(options: { agentDir: string; cwd: string; projectTrusted: boolean }): LoadedMcpConfig { const state: McpConfigState = { servers: new Map(), errors: [] }; readConfigFile(join(options.agentDir, "mcp.json"), "global", state); - if (options.projectTrusted) readConfigFile(join(options.cwd, CONFIG_DIR_NAME, "mcp.json"), "project", state); + const projectConfig = options.projectTrusted ? join(options.cwd, CONFIG_DIR_NAME, "mcp.json") : undefined; + if (projectConfig) readConfigFile(projectConfig, "project", state); return { servers: [...state.servers.values()], ...(state.autoEnableCodemode === undefined ? {} : { autoEnableCodemode: state.autoEnableCodemode }), errors: state.errors, + ...(projectConfig ? { projectConfig } : {}), }; } @@ -130,19 +162,30 @@ export interface McpServerConfigPatch { } /** - * Change one server's settings in the `mcp.json` that defines it. Other content is kept; the file is - * rewritten with its indentation. + * Change one server's settings in the `mcp.json` that defines or overrides it. With `override`, a + * missing entry is added as an override. Overrides keep default values, since they replace the global + * server's. Other content is kept; the file is rewritten with its indentation. */ -export function updateMcpServerConfig(path: string, name: string, patch: McpServerConfigPatch): void { - editMcpServers(path, (servers) => { - const server = servers?.[name]; +export function updateMcpServerConfig( + path: string, + name: string, + patch: McpServerConfigPatch, + options: { override?: boolean } = {}, +): void { + editMcpServers(path, (servers, parsed) => { + let server = servers?.[name]; + if (server === undefined && options.override) { + server = {}; + parsed.mcpServers = { ...servers, [name]: server }; + } if (!isRecord(server)) throw new Error(`${path} does not define MCP server "${name}"`); + const keepDefaults = isOverride(server); if (patch.enabled !== undefined) { - if (patch.enabled) delete server.enabled; - else server.enabled = false; + if (patch.enabled && !keepDefaults) delete server.enabled; + else server.enabled = patch.enabled; } if (patch.exposure !== undefined) { - if (patch.exposure === "codemode") delete server.exposure; + if (patch.exposure === "codemode" && !keepDefaults) delete server.exposure; else server.exposure = patch.exposure; } return true; diff --git a/packages/coding-agent/src/extensions/mcp/index.ts b/packages/coding-agent/src/extensions/mcp/index.ts index 84b42de37..64ceb56c8 100644 --- a/packages/coding-agent/src/extensions/mcp/index.ts +++ b/packages/coding-agent/src/extensions/mcp/index.ts @@ -77,7 +77,10 @@ export interface McpExtensionOptions { logPath?: string; /** Opens the OAuth authorization URL. Defaults to the platform browser. */ openUrl?: (url: string) => void; - /** Saves `/mcp` changes to the server's config file. Defaults to editing its `mcp.json`. */ + /** + * Saves `/mcp` changes to the server's config file: its project `override` when set, else its + * `source`. Defaults to editing that `mcp.json`. + */ updateConfig?: (entry: McpServerEntry, patch: McpServerConfigPatch) => void; /** * How long the first prompt waits for servers with `direct` tools that are still connecting at @@ -276,6 +279,8 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension /** Servers from `mcp.json`, which take precedence over registered servers of the same name. */ let configuredEntries: McpServerEntry[] = []; let configErrors: string[] = []; + /** The trusted project's `mcp.json`, where `/mcp` saves project overrides of global servers. */ + let projectConfig: string | undefined; /** Registered servers that `mcp.json` overrides, shown in `/mcp`. */ let overridden: string[] = []; /** Between session_start and session_shutdown. Registrations before that are read on session_start. */ @@ -297,7 +302,11 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension let serverLog: McpServerLog | undefined; const openUrl = options.openUrl ?? openBrowser; const updateConfig = - options.updateConfig ?? ((entry, patch) => updateMcpServerConfig(entry.source, entry.name, patch)); + options.updateConfig ?? + ((entry, patch) => + updateMcpServerConfig(entry.override ?? entry.source, entry.name, patch, { + override: entry.override !== undefined, + })); const listeners = new Set<() => void>(); const emitChange = () => { @@ -580,17 +589,19 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension /** * Save a config change; returns an error message when the file could not be updated. Changes to - * registered servers only apply to the current session. + * registered servers only apply to the current session. `inProject` adds a project override. */ - const saveConfig = (server: McpServer, patch: McpServerConfigPatch): string | undefined => { - if (server.entry.scope !== "extension") { + const saveConfig = (server: McpServer, patch: McpServerConfigPatch, inProject = false): string | undefined => { + const override = inProject ? projectConfig : server.entry.override; + const entry = override ? { ...server.entry, override } : server.entry; + if (entry.scope !== "extension") { try { - updateConfig(server.entry, patch); + updateConfig(entry, patch); } catch (error) { - return `Could not update ${server.entry.source}: ${errorMessage(error)}`; + return `Could not update ${entry.override ?? entry.source}: ${errorMessage(error)}`; } } - server.entry = { ...server.entry, config: { ...server.entry.config, ...patch } }; + server.entry = { ...entry, config: { ...entry.config, ...patch } }; return undefined; }; @@ -642,8 +653,12 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension }; /** Returns an error message when the config could not be saved; connection errors show in the state. */ - const setEnabled = async (server: McpServer, enabled: boolean): Promise => { - const failed = saveConfig(server, { enabled }); + const setEnabled = async ( + server: McpServer, + enabled: boolean, + inProject = false, + ): Promise => { + const failed = saveConfig(server, { enabled }, inProject); if (failed) return failed; if (!enabled) { const connection = server.connection; @@ -692,7 +707,7 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension .map((server) => ({ value: server.entry.name, label: server.entry.name, - description: `${describeState(server)} · ${exposureOf(server.entry)} · ${server.entry.scope ?? server.entry.source}`, + description: `${describeState(server)} · ${exposureOf(server.entry)} · ${server.entry.override ? "global, project override" : (server.entry.scope ?? server.entry.source)}`, })), empty: `No MCP servers configured. Add them to ${resolve(getAgentDir(), "mcp.json")} or .pi/mcp.json.`, confirmLabel: "manage", @@ -714,12 +729,20 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension const saved = entry.scope === "extension" ? "for this session" - : entry.scope - ? `saved to the ${entry.scope} mcp.json` - : "saved to mcp.json"; + : entry.override + ? "saved to the project mcp.json" + : entry.scope + ? `saved to the ${entry.scope} mcp.json` + : "saved to mcp.json"; + // Global servers without an override can be turned on or off for the trusted project alone. + const inProject = entry.scope === "global" && !entry.override && projectConfig !== undefined; + const inProjectSaved = "saved to the project mcp.json"; const items: SelectItem[] = []; if (!isEnabled(server)) { items.push({ value: "enable", label: "Enable", description: saved }); + if (inProject) { + items.push({ value: "enable-project", label: "Enable in this project", description: inProjectSaved }); + } } else { const state = connection?.state; if (state === "needs-auth") @@ -735,10 +758,14 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension } items.push({ value: "exposure", label: "Exposure", description: exposureOf(entry) }); items.push({ value: "disable", label: "Disable", description: saved }); + if (inProject) { + items.push({ value: "disable-project", label: "Disable in this project", description: inProjectSaved }); + } } const details = [ describeTransport(entry), `${entry.scope ?? "config"}: ${entry.source}`, + ...(entry.override ? [`project override: ${entry.override}`] : []), `State: ${describeState(server, false)}`, ]; const error = [server.message, connection?.state === "connected" ? undefined : connection?.error] @@ -783,7 +810,7 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension details: server.entry.scope === "extension" ? `Applies to this session; the server is registered by ${server.entry.source}.` - : `Saved to ${server.entry.source}.`, + : `Saved to ${server.entry.override ?? server.entry.source}.`, items: (Object.keys(EXPOSURE_DESCRIPTIONS) as (keyof typeof EXPOSURE_DESCRIPTIONS)[]).map((exposure) => ({ value: exposure, label: `${exposure === current ? "✓ " : " "}${exposure}`, @@ -838,9 +865,13 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension break; case "enable": case "disable": - ui.status(`MCP server ${name}`, action === "enable" ? "Connecting…" : "Disconnecting…"); - message = await setEnabled(server, action === "enable"); + case "enable-project": + case "disable-project": { + const enable = action.startsWith("enable"); + ui.status(`MCP server ${name}`, enable ? "Connecting…" : "Disconnecting…"); + message = await setEnabled(server, enable, action.endsWith("-project")); break; + } } server.message = message; ensureDiscoveryActive(ctx); @@ -960,6 +991,7 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension pi.on("session_start", (_event, ctx) => { const loaded = (options.loadConfig ?? defaultLoadConfig)(ctx); configErrors = loaded.errors; + projectConfig = loaded.projectConfig; autoEnableCodemode = loaded.autoEnableCodemode ?? true; warnedUnreachable = false; waitedForStartup = false; diff --git a/packages/coding-agent/test/mcp-extension.test.ts b/packages/coding-agent/test/mcp-extension.test.ts index d6f7acde2..f546b2f3f 100644 --- a/packages/coding-agent/test/mcp-extension.test.ts +++ b/packages/coding-agent/test/mcp-extension.test.ts @@ -14,7 +14,12 @@ import { createInMemoryTransportPair, type InMemoryTransport } from "@earendil-w import { afterEach, describe, expect, it } from "vitest"; import { InMemoryAuthStorageBackend } from "../src/core/auth-storage.ts"; import { truncateMiddle } from "../src/core/tools/truncate.ts"; -import { getMcpToolExposure, loadMcpConfig, type McpServerEntry } from "../src/extensions/mcp/config.ts"; +import { + getMcpToolExposure, + loadMcpConfig, + type McpServerEntry, + updateMcpServerConfig, +} from "../src/extensions/mcp/config.ts"; import { MAX_SERVERS_SECTION_CHARS, renderServersSection } from "../src/extensions/mcp/index.ts"; import { createDefaultTransport, @@ -80,6 +85,35 @@ describe("MCP config", () => { expect(untrusted.servers.find((server) => server.name === "shared")?.config).toEqual({ command: "global-cmd" }); }); + // #10277 + it("lets project entries override enabled and exposure of global servers", () => { + const paths = setup( + { mcpServers: { tools: { command: "x", env: { TOKEN: "secret" } } } }, + // An override cannot change the command, which would run with the global env. + { mcpServers: { tools: { enabled: false, args: ["y"] }, missing: { enabled: false } } }, + ); + const project = join(paths.cwd, ".pi", "mcp.json"); + const { servers, errors } = loadMcpConfig({ ...paths, projectTrusted: true }); + expect(servers.map((server) => [server.name, server.override, server.config])).toEqual([ + ["tools", undefined, { command: "x", env: { TOKEN: "secret" } }], + ]); + expect(errors).toEqual([ + expect.stringContaining('server "tools": an override can only set enabled, exposure, toolExposure'), + expect.stringContaining('server "missing" needs "command" or "url", or a global server to override'), + ]); + + writeFileSync(project, JSON.stringify({ mcpServers: { tools: { enabled: false } } })); + const [tools] = loadMcpConfig({ ...paths, projectTrusted: true }).servers; + expect([tools.override, tools.config]).toEqual([ + project, + { command: "x", env: { TOKEN: "secret" }, enabled: false }, + ]); + + // Overrides keep `enabled: true`, since it replaces the global value. + updateMcpServerConfig(project, "tools", { enabled: true }); + expect(JSON.parse(readFileSync(project, "utf8")).mcpServers.tools).toEqual({ enabled: true }); + }); + // Regression: #10239. it("rejects server names that differ only in - and _", () => { const paths = setup({ mcpServers: { "work-files": { command: "a" }, work_files: { command: "b" } } }, {});