fix(setup): register mcp add clients from the shared client table (#10591)

Table-backed add-mcp agents now project their client-table row instead of
redeclaring format, display name and profile in a second table. The row
also carries add-mcp aliases and project documents. mcp add now applies
the read-scope rules setup already enforced: Gemini settings policy blocks,
the Claude Desktop MSIX write blocker and MSIX AppData redirection.
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 14:14:09 -05:00
1 parent a874fcc4e3
commit 81c115b4c3
8 files changed
+335 -188

No files matched your search

+8
View File
@@ -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
+166 -167
View File
@@ -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<McpRegistrationGlobalLocation>;
readonly projectLocation?: (
context: McpRegistrationLocationContext,
) => Promise<McpRegistrationLocation>;
readonly registrationConnection: ClientRegistrationConnectionProfile;
}
/** An add-mcp agent backed by one client-table row. */
interface TableClient {
readonly kind: "client";
readonly name: SupportedClientName;
}
const tableClient = <const Name extends SupportedClientName>(
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<McpRegistrationGlobalLocation>;
@@ -55,20 +92,10 @@ interface ClientDescriptorBase {
context: McpRegistrationLocationContext,
) => Promise<McpRegistrationLocation>;
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<string, ClientDescriptor>;
} as const satisfies Record<string, CatalogEntry>;
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<string, McpRegistrationClientId>;
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<McpRegistrationClient> => {
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<void> => {
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 =
@@ -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,
{
@@ -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<McpRegistrationGlobalLocation> => {
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<McpRegistrationGlobalLocation> => {
+1 -1
View File
@@ -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)
+64 -11
View File
@@ -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<SetupClient["format"]>;
@@ -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 = <const Definition extends ClientDefinitionInput>(
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
@@ -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",
});
},
);
@@ -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"), {});