diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 0f14a3ba13..52836598cb 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1273,6 +1273,14 @@ repeat keeps the file and creates no new backup. The result reports exact unchanged, failed or not-started outcomes. A partial failure returns a nonzero status while retaining successful client outcomes and recovery paths. +User-level registration for a client that `rea setup` also supports uses that +client's setup definition, so the same native read rules apply. Gemini CLI +registration fails without writing when Gemini's system, defaults or trusted +workspace settings exclude `rea`, leave it off a nonempty allowlist, or define +a different `rea` entry with higher precedence. On Windows, registration refuses +to write a Claude Desktop MSIX configuration the running app could overwrite, +or any file this process's MSIX AppData redirection would divert. + `--no-global` writes only project-level settings. A selection containing Amp or another client without project storage fails before any writes; select those clients in a separate global registration. Managed `rea setup` remains available diff --git a/src/application/McpRegistrationCatalog.ts b/src/application/McpRegistrationCatalog.ts index d1bb301684..cc7a9a61ad 100644 --- a/src/application/McpRegistrationCatalog.ts +++ b/src/application/McpRegistrationCatalog.ts @@ -10,9 +10,14 @@ import { ConfigurationError } from "../domain/configurationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; import type { ClientConfigurationFormat } from "./ClientConfigurationDocument.js"; import { + appDataRedirection, + registrationWriteBlocker, + supportedClientDefinition, supportedClients, - supportedClientRegistrationProfile, + type SetupClient, + type SupportedClientName, } from "./SupportedClients.js"; +import { redirectedWriteBlocker } from "./MsixPackageStorage.js"; import type { ClientRegistrationConnectionProfile, ClientRegistrationProfile, @@ -30,7 +35,6 @@ import { present, fixedProjectLocation, clineLocation, - clineCliLocation, fxLocation, gooseLocation, junieLocation, @@ -45,9 +49,42 @@ import { type McpRegistrationInlineConfiguration, } from "./McpRegistrationLocations.js"; -interface ClientDescriptorBase { +/** + * A client `rea mcp add` knows but the client table does not yet hold. Its + * facts are declared here until it joins that table; every other client + * projects its table row. + */ +interface RegistrationOnlyDescriptor { + readonly kind: "registration-only"; readonly displayName: string; readonly format: ClientConfigurationFormat; + readonly aliases?: readonly string[]; + readonly globalLocation: ( + context: McpRegistrationLocationContext, + ) => Promise; + readonly projectLocation?: ( + context: McpRegistrationLocationContext, + ) => Promise; + readonly registrationConnection: ClientRegistrationConnectionProfile; +} + +/** An add-mcp agent backed by one client-table row. */ +interface TableClient { + readonly kind: "client"; + readonly name: SupportedClientName; +} + +const tableClient = ( + name: Name, +): TableClient & { readonly name: Name } => ({ kind: "client", name }); + +type CatalogEntry = TableClient | RegistrationOnlyDescriptor; + +/** The facts registration needs, from the client's single owner. */ +interface ClientDescriptor { + readonly displayName: string; + readonly format: ClientConfigurationFormat; + readonly aliases: readonly string[]; readonly globalLocation: ( context: McpRegistrationLocationContext, ) => Promise; @@ -55,20 +92,10 @@ interface ClientDescriptorBase { context: McpRegistrationLocationContext, ) => Promise; readonly projectFormat?: ClientConfigurationFormat; + readonly registrationProfile: ClientRegistrationProfile; + readonly setupName?: SupportedClientName; } -type ClientDescriptor = ClientDescriptorBase & - ( - | { - readonly registrationProfile: ClientRegistrationProfile; - readonly registrationConnection?: never; - } - | { - readonly registrationConnection: ClientRegistrationConnectionProfile; - readonly registrationProfile?: never; - } - ); - /** mcporter getUrl's native nullish priority; an empty first value suppresses later aliases. */ const MCPORTER_URL_FIELDS = [ "baseUrl", @@ -102,61 +129,29 @@ const MCPORTER_STDIO_INCOMPATIBILITIES: readonly NativeStdioIncompatibility[] = ), ]; -/** Reviewed add-mcp 2.6.1 client contracts plus Incur's separate Amp contract. */ +/** + * Reviewed add-mcp 2.6.1 agent ids plus Incur's separate Amp contract, in + * add-mcp's order. Table-backed agents carry only their row name; the row owns + * the location, format, profile, aliases, project documents and read scope. + */ export const MCP_REGISTRATION_CLIENTS = { - antigravity: { - displayName: "Antigravity", - format: "json", - globalLocation: fromSetup("antigravity"), - registrationProfile: supportedClientRegistrationProfile("antigravity"), - }, + antigravity: tableClient("antigravity"), cline: { + kind: "registration-only", displayName: "Cline VSCode Extension", format: "json", + aliases: ["cline-vscode"], globalLocation: clineLocation, registrationConnection: { kind: "cline" }, }, - "cline-cli": { - displayName: "Cline CLI", - format: "json", - globalLocation: clineCliLocation, - registrationConnection: { kind: "cline" }, - }, - "claude-code": { - displayName: "Claude Code", - format: "claude_code", - globalLocation: fromSetup("claude_code"), - registrationProfile: supportedClientRegistrationProfile("claude_code"), - projectLocation: fixedProjectLocation([".mcp.json"]), - }, - "claude-desktop": { - displayName: "Claude Desktop", - format: "json", - globalLocation: fromSetup("claude_desktop"), - registrationProfile: supportedClientRegistrationProfile("claude_desktop"), - }, - codex: { - displayName: "Codex", - format: "toml", - globalLocation: fromSetup("codex"), - registrationProfile: supportedClientRegistrationProfile("codex"), - projectLocation: fixedProjectLocation([".codex/config.toml"]), - }, - cursor: { - displayName: "Cursor", - format: "json", - globalLocation: fromSetup("cursor"), - registrationProfile: supportedClientRegistrationProfile("cursor"), - projectLocation: fixedProjectLocation([".cursor/mcp.json"]), - }, - "factory-droid": { - displayName: "Factory Droid", - format: "json", - globalLocation: fromSetup("droid"), - registrationProfile: supportedClientRegistrationProfile("droid"), - projectLocation: fixedProjectLocation([".factory/mcp.json"]), - }, + "cline-cli": tableClient("cline"), + "claude-code": tableClient("claude_code"), + "claude-desktop": tableClient("claude_desktop"), + codex: tableClient("codex"), + cursor: tableClient("cursor"), + "factory-droid": tableClient("droid"), fx: { + kind: "registration-only", displayName: "fx", format: "fx", globalLocation: fxLocation, @@ -175,14 +170,9 @@ export const MCP_REGISTRATION_CLIENTS = { ], }, }, - "gemini-cli": { - displayName: "Gemini CLI", - format: "json", - globalLocation: fromSetup("gemini_cli"), - registrationProfile: supportedClientRegistrationProfile("gemini_cli"), - projectLocation: fixedProjectLocation([".gemini/settings.json"]), - }, + "gemini-cli": tableClient("gemini_cli"), goose: { + kind: "registration-only", displayName: "Goose", format: "goose", globalLocation: gooseLocation, @@ -192,28 +182,11 @@ export const MCP_REGISTRATION_CLIENTS = { stdio: [{ kind: "required-enum", field: "type", values: ["stdio"] }], }, }, - "github-copilot-cli": { - displayName: "GitHub Copilot CLI", - format: "copilot_cli", - globalLocation: fromSetup("copilot_cli"), - registrationProfile: supportedClientRegistrationProfile("copilot_cli"), - projectLocation: fixedProjectLocation([".mcp.json", ".github/mcp.json"]), - projectFormat: "copilot_cli_project", - }, - "grok-build": { - displayName: "Grok Build", - format: "grok", - globalLocation: fromSetup("grok_build"), - registrationProfile: supportedClientRegistrationProfile("grok_build"), - projectLocation: fixedProjectLocation([".grok/config.toml"]), - }, - hermes: { - displayName: "Hermes Agent", - format: "hermes", - globalLocation: fromSetup("hermes"), - registrationProfile: supportedClientRegistrationProfile("hermes"), - }, + "github-copilot-cli": tableClient("copilot_cli"), + "grok-build": tableClient("grok_build"), + hermes: tableClient("hermes"), junie: { + kind: "registration-only", displayName: "Junie", format: "json", globalLocation: junieLocation, @@ -225,8 +198,10 @@ export const MCP_REGISTRATION_CLIENTS = { }, }, "kilo-code": { + kind: "registration-only", displayName: "Kilo Code", format: "kilo", + aliases: ["kilo", "kilocode"], globalLocation: kiloLocation, projectLocation: kiloProjectLocation, registrationConnection: { @@ -235,16 +210,12 @@ export const MCP_REGISTRATION_CLIENTS = { stdio: [{ kind: "required-enum", field: "type", values: ["local"] }], }, }, - "kimi-code": { - displayName: "Kimi Code", - format: "kimi", - globalLocation: fromSetup("kimi"), - registrationProfile: supportedClientRegistrationProfile("kimi"), - projectLocation: fixedProjectLocation([".kimi-code/mcp.json"]), - }, + "kimi-code": tableClient("kimi"), "kiro-cli": { + kind: "registration-only", displayName: "Kiro CLI", format: "json", + aliases: ["kiro"], globalLocation: kiroLocation, projectLocation: fixedProjectLocation([".kiro/settings/mcp.json"]), registrationConnection: { @@ -257,8 +228,10 @@ export const MCP_REGISTRATION_CLIENTS = { }, }, mastracode: { + kind: "registration-only", displayName: "Mastra Code", format: "json", + aliases: ["mastra"], globalLocation: mastraLocation, projectLocation: fixedProjectLocation([".mastracode/mcp.json"]), registrationConnection: { @@ -268,6 +241,7 @@ export const MCP_REGISTRATION_CLIENTS = { }, }, mcporter: { + kind: "registration-only", displayName: "MCPorter", format: "json", globalLocation: mcporterLocation, @@ -278,39 +252,12 @@ export const MCP_REGISTRATION_CLIENTS = { stdio: MCPORTER_STDIO_INCOMPATIBILITIES, }, }, - opencode: { - displayName: "OpenCode", - format: "opencode", - globalLocation: fromSetup("opencode"), - registrationProfile: supportedClientRegistrationProfile("opencode"), - projectLocation: fixedProjectLocation([ - "opencode.jsonc", - "opencode.json", - ".opencode/opencode.jsonc", - ".opencode/opencode.json", - ]), - }, - pi: { - displayName: "Pi", - format: "pi", - globalLocation: fromSetup("pi"), - registrationProfile: supportedClientRegistrationProfile("pi"), - projectLocation: fixedProjectLocation([".pi/mcp.json"]), - }, - vscode: { - displayName: "VS Code", - format: "vscode", - globalLocation: fromSetup("vscode"), - registrationProfile: supportedClientRegistrationProfile("vscode"), - projectLocation: fixedProjectLocation([".vscode/mcp.json"]), - }, - windsurf: { - displayName: "Windsurf", - format: "json", - globalLocation: fromSetup("windsurf"), - registrationProfile: supportedClientRegistrationProfile("windsurf"), - }, + opencode: tableClient("opencode"), + pi: tableClient("pi"), + vscode: tableClient("vscode"), + windsurf: tableClient("windsurf"), zed: { + kind: "registration-only", displayName: "Zed", format: "zed", globalLocation: zedLocation, @@ -322,6 +269,7 @@ export const MCP_REGISTRATION_CLIENTS = { }, }, amp: { + kind: "registration-only", displayName: "Amp", format: "amp", globalLocation: ampLocation, @@ -331,37 +279,56 @@ export const MCP_REGISTRATION_CLIENTS = { stdio: [{ kind: "present", field: "url" }], }, }, -} as const satisfies Record; +} as const satisfies Record; export type McpRegistrationClientId = keyof typeof MCP_REGISTRATION_CLIENTS; -const ALIASES = { - "cline-vscode": "cline", - codeium: "windsurf", - cascade: "windsurf", - droid: "factory-droid", - factory: "factory-droid", - gemini: "gemini-cli", - "github-copilot": "vscode", - grok: "grok-build", - "hermes-agent": "hermes", - kilo: "kilo-code", - kilocode: "kilo-code", - kimi: "kimi-code", - kiro: "kiro-cli", - mastra: "mastracode", - "pi-agent": "pi", -} as const satisfies Record; - const isClientId = (value: string): value is McpRegistrationClientId => Object.hasOwn(MCP_REGISTRATION_CLIENTS, value); +/** Project one agent's facts from its owner: the table row, or its pending descriptor. */ +const clientDescriptor = (id: McpRegistrationClientId): ClientDescriptor => { + const entry: CatalogEntry = MCP_REGISTRATION_CLIENTS[id]; + if (entry.kind === "registration-only") { + const { registrationConnection, ...facts } = entry; + return { + aliases: [], + ...facts, + // Pending clients bind their add-mcp id as the native identity. + registrationProfile: { + nativeClient: id, + connection: registrationConnection, + }, + }; + } + const row = supportedClientDefinition(entry.name); + if (row.format === "unsupported") + throw new Error(`${row.name} has no registration format`); + const projectPaths = row.mcpAdd?.projectPaths; + return { + displayName: row.displayName, + format: row.format, + aliases: row.mcpAdd?.aliases ?? [], + globalLocation: fromSetup(entry.name), + ...(projectPaths === undefined + ? {} + : { projectLocation: fixedProjectLocation(projectPaths) }), + ...(row.mcpAdd?.projectFormat === undefined + ? {} + : { projectFormat: row.mcpAdd.projectFormat }), + registrationProfile: row.registrationProfile, + setupName: entry.name, + }; +}; + const resolveMcpRegistrationClientId = ( input: string, ): McpRegistrationClientId | undefined => { const lower = input.toLowerCase(); if (isClientId(lower)) return lower; - return Object.entries(ALIASES).find(([alias]) => alias === lower)?.[1]; + return Object.keys(MCP_REGISTRATION_CLIENTS) + .filter(isClientId) + .find((id) => clientDescriptor(id).aliases.includes(lower)); }; export interface McpRegistrationClient { @@ -375,6 +342,11 @@ export interface McpRegistrationClient { readonly scope: "global" | "project"; readonly format: ClientConfigurationFormat; readonly privateProfile?: true; + /** + * The table row resolved for this user-scope registration. Its read-scope + * facts (Gemini settings policy, MSIX package storage) govern the write. + */ + readonly setupClient?: SetupClient; } export interface McpRegistrationSelection { @@ -408,24 +380,16 @@ const selectedIds = (agents: readonly string[]): McpRegistrationClientId[] => { return ids; }; -/** Existing setup bindings retain their exact profile; additional clients bind the descriptor key. */ +/** Table rows retain their exact profile; pending clients bind the descriptor key. */ export const mcpClientRegistrationProfile = ( id: McpRegistrationClientId, -): ClientRegistrationProfile => { - const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id]; - return ( - descriptor.registrationProfile ?? { - nativeClient: id, - connection: descriptor.registrationConnection, - } - ); -}; +): ClientRegistrationProfile => clientDescriptor(id).registrationProfile; const projectTarget = async ( id: McpRegistrationClientId, context: McpRegistrationLocationContext, ): Promise => { - const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id]; + const descriptor = clientDescriptor(id); if (descriptor.projectLocation === undefined) throw new ConfigurationError( `${descriptor.displayName} has no project-scoped registration. Remove --no-global or select a project-capable client; no settings were written.`, @@ -445,8 +409,12 @@ const globalTarget = async ( id: McpRegistrationClientId, context: McpRegistrationLocationContext, ): Promise<{ client: McpRegistrationClient; markers: readonly string[] }> => { - const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id]; + const descriptor = clientDescriptor(id); const resolved = await descriptor.globalLocation(context); + const setupClient = + descriptor.setupName === undefined + ? undefined + : context.setup.find(({ name }) => name === descriptor.setupName); return { client: { id, @@ -466,11 +434,34 @@ const globalTarget = async ( ? {} : { inlineConfiguration: resolved.inlineConfiguration }), ...(id === "fx" ? { privateProfile: true } : {}), + ...(setupClient === undefined ? {} : { setupClient }), }, markers: resolved.markers.map((marker) => selectedPath(marker, context)), }; }; +/** + * Refuse writes a running MSIX client would overwrite, or that this process's + * MSIX AppData redirection would divert, exactly as setup does. + */ +const msixWriteBlocker = ( + clients: readonly McpRegistrationClient[], + context: McpRegistrationLocationContext, +): string | undefined => { + for (const { setupClient } of clients) { + const blocker = + setupClient === undefined + ? undefined + : registrationWriteBlocker(setupClient, "mcp add"); + if (blocker !== undefined) return blocker; + } + return redirectedWriteBlocker( + appDataRedirection(context.setup), + clients.map(({ configPath }) => configPath), + "mcp add", + ); +}; + const validateJunieGlobal = async ( client: McpRegistrationClient, context: McpRegistrationLocationContext, @@ -538,6 +529,18 @@ const validateJunieGlobal = async ( } }; +/** User-scope read-scope rules that must hold before any target is written. */ +const admitGlobalTargets = async ( + clients: readonly McpRegistrationClient[], + context: McpRegistrationLocationContext, +): Promise => { + const junie = clients.find((client) => client.id === "junie"); + if (junie) await validateJunieGlobal(junie, context); + const blocker = msixWriteBlocker(clients, context); + if (blocker !== undefined) + throw new ConfigurationError(`${blocker} No settings were written.`); +}; + /** Resolve all selected paths before any registration writer receives authority. */ export const resolveMcpRegistrationClients = async ( selection: McpRegistrationSelection, @@ -572,10 +575,7 @@ export const resolveMcpRegistrationClients = async ( : await projectTarget(id, context), ); } - if (selection.global) { - const junie = clients.find((client) => client.id === "junie"); - if (junie) await validateJunieGlobal(junie, context); - } + if (selection.global) await admitGlobalTargets(clients, context); checkCancellation(selection.signal); return ok(clients); } @@ -594,8 +594,7 @@ export const resolveMcpRegistrationClients = async ( const amp = (await globalTarget("amp", context)).client; checkCancellation(selection.signal); const selected = [...(detected.length === 0 ? all : detected), amp]; - const junie = selected.find((client) => client.id === "junie"); - if (junie) await validateJunieGlobal(junie, context); + await admitGlobalTargets(selected, context); return ok(selected); } catch (cause: unknown) { const failure = diff --git a/src/application/McpRegistrationConfiguration.ts b/src/application/McpRegistrationConfiguration.ts index e7ff4adacc..444fe9381e 100644 --- a/src/application/McpRegistrationConfiguration.ts +++ b/src/application/McpRegistrationConfiguration.ts @@ -18,6 +18,7 @@ import { readConfigurationFile, } from "./ClientConfigurationFiles.js"; import { replaceClientRegistrationLauncher } from "./ClientRegistration.js"; +import { readClientPolicyBlock } from "./GeminiClientSettings.js"; import { admitClientStdioConnection } from "./ClientRegistrationConnection.js"; import type { McpRegistrationClient } from "./McpRegistrationCatalog.js"; import { readRegularFileTextSnapshot } from "./RegularFileRead.js"; @@ -62,6 +63,21 @@ export const prepareRegistration = async ( : effectiveClientServer(inherited, PRODUCT_IDENTITY.mcpServerKey), ); if (!desired.ok) throw desired.error; + // The client's own settings policy can exclude or override this entry. + if (client.setupClient !== undefined) { + const policy = await readClientPolicyBlock( + client.setupClient, + parsed, + desired.value, + ); + if (!policy.ok) + throw ( + policy.error.cleanupFailure ?? + new ConfigurationError(policy.error.detail) + ); + if (policy.value !== undefined) + throw new ConfigurationError(`${policy.value} No settings were written.`); + } const updated = withClientServers( parsed, { diff --git a/src/application/McpRegistrationLocations.ts b/src/application/McpRegistrationLocations.ts index b5ff1dbabd..7a13233987 100644 --- a/src/application/McpRegistrationLocations.ts +++ b/src/application/McpRegistrationLocations.ts @@ -1,7 +1,6 @@ import { lstat } from "node:fs/promises"; import { dirname, join, posix, win32 } from "node:path"; -import { resolveClineCliSettingsPath } from "./ClineCliLocation.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import type { SetupClient, SupportedClientName } from "./SupportedClients.js"; @@ -120,14 +119,6 @@ export const clineLocation = async ( "cline_mcp_settings.json", ), ); -export const clineCliLocation = async ( - context: McpRegistrationLocationContext, -): Promise => { - const selected = resolveClineCliSettingsPath(context); - if (!selected.ok) throw selected.error; - const pathApi = context.platform === "win32" ? win32 : posix; - return location(selected.value, pathApi.dirname(selected.value)); -}; export const gooseLocation = async ( context: McpRegistrationLocationContext, ): Promise => { diff --git a/src/application/MsixPackageStorage.ts b/src/application/MsixPackageStorage.ts index 13dbefba8a..73d42b8524 100644 --- a/src/application/MsixPackageStorage.ts +++ b/src/application/MsixPackageStorage.ts @@ -141,7 +141,7 @@ const canonicalDestination = ( export const redirectedWriteBlocker = ( view: MsixPackageView | undefined, targets: readonly string[], - operation: "setup" | "uninstall", + operation: "setup" | "uninstall" | "mcp add", canonicalPath: (path: string) => string = realpathSync.native, ): string | undefined => { if (view?.processView !== "redirected" || targets.length === 0) diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index edbf23e17d..f8f00c321a 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -2,6 +2,7 @@ import type { ClientRegistrationConnectionProfile, ClientRegistrationProfile, } from "./ClientRegistrationProfile.js"; +import type { ClientConfigurationFormat } from "./ClientConfigurationDocument.js"; import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync, lstatSync, readFileSync } from "node:fs"; @@ -113,10 +114,25 @@ type ClientDefinitionLocation = readonly markerPath?: undefined; }; +/** + * How `rea mcp add` selects this client beyond its user-scope registration. + * The registration itself uses this row's location, format, profile and + * read-scope facts, so both commands share one definition. + */ +interface McpAddExposure { + /** add-mcp agent aliases that select this client. */ + readonly aliases?: readonly string[]; + /** Project documents in native precedence; absent means global-only. */ + readonly projectPaths?: readonly [string, ...string[]]; + /** Dialect of the project document when it differs from the user one. */ + readonly projectFormat?: ClientConfigurationFormat; +} + interface ClientDefinitionBase { readonly name: string; readonly registrationProfile: ClientRegistrationProfile; readonly displayName: string; + readonly mcpAdd?: McpAddExposure; readonly discoveryPaths?: (context: ClientPathContext) => readonly string[]; readonly sharedRegistration?: true; readonly format: NonNullable; @@ -347,16 +363,16 @@ export const registrationPersistenceDetail = ( */ export const registrationWriteBlocker = ( client: SetupClient, - operation: "setup" | "uninstall", + operation: "setup" | "uninstall" | "mcp add", ): string | undefined => { const msix = client.msixPackage; if (msix === undefined || msix.processView === "direct") return undefined; const name = client.displayName ?? client.name; if (msix.processView === "redirected") return `This process was started from ${name}'s MSIX package ${msix.packageFamilyName}: its AppData is redirected into that package, so ${name} is running and would overwrite ${msix.privateConfigPath} from memory. Quit ${name} fully, then rerun ${operation} from a terminal that ${name} did not start.`; - // Setup would write the configuration the app reads; uninstall cleans both files. + // Registration writes the configuration the app reads; uninstall cleans both files. const manualEdit = - operation === "setup" + operation !== "uninstall" ? `Add REA's entry to ${client.configPath} by hand` : `Remove REA's entry by hand from each of ${msix.privateConfigPath} and ${msix.appDataConfigPath} that has one`; return `REA cannot tell whether ${name}'s MSIX package ${msix.packageFamilyName} started this process, because no private file under ${dirname(dirname(msix.privateConfigPath))} could be compared with ${dirname(dirname(msix.appDataConfigPath))}, and a ${name} that started it would overwrite the change from memory. ${manualEdit} while ${name} is fully quit, or rerun ${operation} from a terminal that ${name} did not start once REA can compare a private file there.`; @@ -583,6 +599,7 @@ const defineClient = ( export const SUPPORTED_CLIENT_DEFINITIONS = [ defineClient({ name: "claude_code", + mcpAdd: { projectPaths: [".mcp.json"] }, registrationConnection: { kind: "flat", fields: ["type", "url"], @@ -601,6 +618,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "claude_desktop", + mcpAdd: {}, registrationConnection: { kind: "flat", fields: ["type"], @@ -613,6 +631,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "codex", + mcpAdd: { projectPaths: [".codex/config.toml"] }, registrationConnection: { kind: "flat", fields: ["url"], @@ -626,6 +645,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "cursor", + mcpAdd: { projectPaths: [".cursor/mcp.json"] }, registrationConnection: { kind: "flat", fields: ["type", "url"], @@ -655,6 +675,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ // Cline discovers the shared ~/.agents/skills directory. Keep REA's // existing shared destination instead of managing another skill copy. name: "cline", + mcpAdd: {}, registrationConnection: { kind: "cline" }, displayName: "Cline CLI", location: ({ home, env, platform }: ClientPathContext) => { @@ -675,6 +696,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "gemini_cli", + mcpAdd: { aliases: ["gemini"], projectPaths: [".gemini/settings.json"] }, registrationConnection: { kind: "flat", fields: ["type", "url", "httpUrl"], @@ -706,6 +728,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "windsurf", + mcpAdd: { aliases: ["codeium", "cascade"] }, registrationConnection: { kind: "flat", fields: ["serverUrl", "url"], @@ -737,6 +760,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "opencode", + mcpAdd: { + projectPaths: [ + "opencode.jsonc", + "opencode.json", + ".opencode/opencode.jsonc", + ".opencode/opencode.json", + ], + }, registrationConnection: { kind: "flat", fields: ["url"], @@ -750,6 +781,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "antigravity", + mcpAdd: {}, registrationConnection: { kind: "flat", fields: ["serverUrl", "url"], @@ -766,6 +798,10 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "copilot_cli", + mcpAdd: { + projectPaths: [".mcp.json", ".github/mcp.json"], + projectFormat: "copilot_cli_project", + }, registrationConnection: { kind: "flat", fields: ["url"], @@ -808,6 +844,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "kimi", + mcpAdd: { aliases: ["kimi"], projectPaths: [".kimi-code/mcp.json"] }, registrationConnection: { kind: "flat", fields: ["type", "url", "transport"], @@ -825,6 +862,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "vscode", + mcpAdd: { aliases: ["github-copilot"], projectPaths: [".vscode/mcp.json"] }, registrationConnection: { kind: "flat", fields: ["url", "transport"], @@ -876,6 +914,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "grok_build", + mcpAdd: { aliases: ["grok"], projectPaths: [".grok/config.toml"] }, registrationConnection: { kind: "flat", fields: ["url"], @@ -904,6 +943,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "pi", + mcpAdd: { aliases: ["pi-agent"], projectPaths: [".pi/mcp.json"] }, registrationConnection: { kind: "flat", fields: ["url"], @@ -929,6 +969,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "hermes", + mcpAdd: { aliases: ["hermes-agent"] }, registrationConnection: { kind: "flat", fields: ["url", "transport"], @@ -967,6 +1008,10 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }), defineClient({ name: "droid", + mcpAdd: { + aliases: ["droid", "factory"], + projectPaths: [".factory/mcp.json"], + }, registrationConnection: { kind: "flat", fields: ["type", "url"], @@ -1062,17 +1107,25 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ export type SupportedClientName = (typeof SUPPORTED_CLIENT_DEFINITIONS)[number]["name"]; +/** One client's row, for selection surfaces such as `rea mcp add` that project it. */ +export const supportedClientDefinition = ( + name: SupportedClientName, +): Pick< + ClientDefinition, + "name" | "displayName" | "format" | "registrationProfile" | "mcpAdd" +> => { + const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS; + const definition = definitions.find((candidate) => candidate.name === name); + if (definition === undefined) + throw new Error(`No supported client definition for ${name}`); + return definition; +}; + /** Setup and registration consume the same already-bound native profile object. */ export const supportedClientRegistrationProfile = ( name: SupportedClientName, -): ClientRegistrationProfile => { - const definition = SUPPORTED_CLIENT_DEFINITIONS.find( - (candidate) => candidate.name === name, - ); - if (definition === undefined) - throw new Error(`No supported client definition for ${name}`); - return definition.registrationProfile; -}; +): ClientRegistrationProfile => + supportedClientDefinition(name).registrationProfile; /** * Paths whose presence means the product itself is applicable, so doctor and diff --git a/tests/boundary/cli/mcpAddClientPolicy.test.ts b/tests/boundary/cli/mcpAddClientPolicy.test.ts new file mode 100644 index 0000000000..934cbb8993 --- /dev/null +++ b/tests/boundary/cli/mcpAddClientPolicy.test.ts @@ -0,0 +1,50 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { mcpRegistrationReportSchema } from "../../../src/contracts/mcpRegistration.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { registrationEnvironment } from "../../support/cli/registrationEnvironment.js"; + +// `rea mcp add` registers Gemini CLI through the same client-table row as +// setup, so Gemini's settings policy governs both writers. +cliTest( + "mcp add refuses a Gemini registration that system settings exclude", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-mcp-gemini-policy-"); + const system = join(home, "system", "settings.json"); + await mkdir(join(home, "system"), { recursive: true }); + await writeFile(system, '{"mcp":{"excluded":["rea"]}}\n'); + const user = join(home, ".gemini", "settings.json"); + await mkdir(join(home, ".gemini"), { recursive: true }); + const original = '{"theme":"dark"}\n'; + await writeFile(user, original); + const environment = await registrationEnvironment(home, { + GEMINI_CLI_SYSTEM_SETTINGS_PATH: system, + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "system", "defaults.json"), + GEMINI_CLI_TRUSTED_FOLDERS_PATH: join(home, "trusted.json"), + }); + const result = await cli.run({ + arguments: ["mcp", "add", "--agent", "gemini", "--json"], + cwd: home, + environment, + }); + expect(result.exitCode, result.stderr).toBe(1); + const report = mcpRegistrationReportSchema.parse(result.json); + expect(report.clients).toEqual([ + expect.objectContaining({ + client: "gemini-cli", + config_path: user, + status: "failed", + error: expect.objectContaining({ + reason: expect.stringContaining("Gemini mcp.excluded blocks rea"), + }), + }), + ]); + expect(await readFile(user, "utf8")).toBe(original); + await expect(access(`${user}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); diff --git a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts index 7eccca93b2..3ba2360882 100644 --- a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts +++ b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts @@ -30,6 +30,8 @@ import { runUninstall, systemUninstallHost, } from "../../../src/application/Uninstall.js"; +import { resolveMcpRegistrationClients } from "../../../src/application/McpRegistrationCatalog.js"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -145,6 +147,34 @@ describe("Windows Claude Desktop configuration location", () => { ); }); + it("refuses mcp add before any write while the package view is unknown", async () => { + const { home, env, appDataConfig, container } = await windowsProfile(); + await mkdir(join(container, "LocalCache", "Roaming"), { recursive: true }); + await writeJson(appDataConfig, { preferences: {} }); + const resources = new ArtifactResourceScope(); + try { + const selected = await resolveMcpRegistrationClients( + { + agents: ["claude-desktop"], + global: true, + cwd: home, + home, + platform: "win32", + environment: env, + }, + resources, + ); + expect(selected.ok).toBe(false); + if (selected.ok) throw new Error("expected an MSIX write refusal"); + expect(selected.error.message).toContain( + `Add REA's entry to ${appDataConfig} by hand while Claude Desktop is fully quit, or rerun mcp add`, + ); + } finally { + await resources.close(); + } + expect(await readJson(appDataConfig)).toEqual({ preferences: {} }); + }); + it("creates the private copy when the MSIX package has no configuration", async () => { const { home, env, privateConfig } = await windowsProfile(); await writeJson(join(dirname(privateConfig), "Local State"), {});