mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
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:
1 parent
a874fcc4e3
commit
81c115b4c3
8 files changed
+335
-188
No files matched your search
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"), {});
|
||||
|
||||
Reference in new issue
Block a user