Compare commits

...
Author SHA1 Message Date
Kaoru0822 b180055aab feat(setup): expose explicit MCP catalog profile selection 2026-11-08 05:32:12 -08:00
6 changed files with 192 additions and 3 deletions

No files matched your search

+19 -1
View File
@@ -109,7 +109,25 @@ unfamiliar prefix. The unavailable fork reported in
For a client that supports MCP catalog refresh, set
`REA_MCP_TOOL_CATALOG_PROFILE=hot-cold` in REA's server environment and restart
the connection. REA then advertises a small initial catalog and loads other
named tools through `discover_tools`. Setup preserves this explicit setting.
named tools through `discover_tools`. Setup can record the explicit choice:
```bash
rea setup --client codex --tool-catalog-profile hot-cold --dry-run
rea setup --client codex --tool-catalog-profile hot-cold
```
The plan shows `REA_MCP_TOOL_CATALOG_PROFILE=hot-cold` before approval. This
selects server catalog loading; enable and verify the client's catalog refresh
separately. Setup preserves its existing feature flags and tool approval lists.
To restore upfront advertisement, use `--tool-catalog-profile full`, then
reconnect. Omitting the option preserves the saved profile and keeps `full` for
a new registration. Existing explicit environment selections are also retained.
Setup does not infer refresh from a client name or enable experimental client
features. The verified Codex trial below covers one version with an explicit
flag; FastMCP was tested with explicit refresh calls. Neither establishes
automatic refresh for every installed version or other client. Keep `full`
until refresh is established for the selected client and configuration.
See the [discovery contract](../reference/mcp-contracts.md#hotcold-catalog-discovery) for
pagination, identity and family-scope semantics.
@@ -754,3 +754,26 @@ describe("setup tool family scope", () => {
);
});
});
it("discloses the selected catalog profile before applying it with the family scope", async () => {
const host = new FakeSetupHost();
host.doctorHealthy = true;
host.skill = "unchanged";
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const selection = {
clientIds: ["cursor"],
toolCatalogProfile: "hot-cold" as const,
toolFamilies: "session,application",
};
const plan = await runSetup({ ...options(false), ...selection }, host);
expect(plan.plannedActions[0]?.detail).toContain(
"REA_MCP_TOOL_CATALOG_PROFILE=hot-cold",
);
expect(host.configuredProviderEnvironments).toEqual([]);
const applied = await runSetup({ ...options(true), ...selection }, host);
expect(applied.status).toBe("ready");
expect(host.configuredProviderEnvironments).toContainEqual({
REA_MCP_TOOL_FAMILIES: "session,application",
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
});
});
+7 -2
View File
@@ -131,14 +131,19 @@ const runSetupWorkflow = async (
options.proposeHopper ?? (confirm !== undefined && !options.structured),
initialDoctor: initialState.doctor,
});
// The advertised family scope is a registration setting, not a detected
// provider fact; merge it after discovery so it cannot suppress the
// Catalog loading and family scope are registration settings, not detected
// provider facts; merge them after discovery so they cannot suppress the
// Hopper installation offer on an otherwise unconfigured machine.
if (options.toolFamilies !== undefined)
providerEnvironment = {
...providerEnvironment,
REA_MCP_TOOL_FAMILIES: options.toolFamilies,
};
if (options.toolCatalogProfile !== undefined)
providerEnvironment = {
...providerEnvironment,
REA_MCP_TOOL_CATALOG_PROFILE: options.toolCatalogProfile,
};
clientStates = discovery.clientStates;
const clientSelectionAllowed =
options.clientIds === undefined &&
+3
View File
@@ -1,4 +1,5 @@
import type { ClientConfigurationWriteProblem } from "./ClientConfigurationPublication.js";
import type { McpToolCatalogProfile } from "../config/mcpToolCatalogProfile.js";
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
import type { LinuxDistribution } from "./LinuxHopper.js";
import type { HopperRequestFailure } from "./HopperDownload.js";
@@ -240,6 +241,8 @@ export interface SetupOptions {
readonly installSkill?: boolean;
/** Comma-separated tool families the MCP registration advertises. */
readonly toolFamilies?: string;
/** Explicit catalog loading policy; omitted preserves the saved selection. */
readonly toolCatalogProfile?: McpToolCatalogProfile;
readonly readinessScope?: DoctorScope;
readonly dryRun?: boolean;
readonly allDetectedClients?: boolean;
+13
View File
@@ -14,6 +14,10 @@ import {
toolFamiliesConstraint,
} from "../config/mcpToolFamilies.js";
import { isUpdateFailure } from "../application/UpdateFailure.js";
import {
mcpToolCatalogProfileSchema,
type McpToolCatalogProfile,
} from "../config/mcpToolCatalogProfile.js";
import {
confirmInteractiveSetup,
renderSetupDryRun,
@@ -79,6 +83,11 @@ const registerSetupCommand = (
.describe(
"Comma-separated tool families this registration advertises; omit to preserve the saved selection, or advertise every tool if none is saved. To restore every tool, remove REA_MCP_TOOL_FAMILIES from the client's registration and restart the client.",
),
toolCatalogProfile: mcpToolCatalogProfileSchema
.optional()
.describe(
"MCP catalog: full advertises every in-scope tool; hot-cold loads tools on demand and requires client catalog refresh. Omit to preserve the saved profile, or use full if none is saved. Restart the client after changing it.",
),
allDetected: z
.boolean()
.default(false)
@@ -210,6 +219,7 @@ interface SetupCommandOptions {
readonly client: readonly string[];
readonly skillClient: readonly string[];
readonly toolFamilies?: string | undefined;
readonly toolCatalogProfile?: McpToolCatalogProfile | undefined;
readonly allDetected: boolean;
readonly skill?: boolean | undefined;
readonly dryRun: boolean;
@@ -285,6 +295,9 @@ const setupRunOptions = (
...(options.toolFamilies === undefined
? {}
: { toolFamilies: options.toolFamilies }),
...(options.toolCatalogProfile === undefined
? {}
: { toolCatalogProfile: options.toolCatalogProfile }),
...(interactive ? { onProgress: renderSetupProgress } : {}),
...(hasSelectedClients && !options.allDetected
? {
+127
View File
@@ -0,0 +1,127 @@
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { parse as parseToml } from "smol-toml";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.for(["codex", "cursor"] as const)(
"plans, preserves and reverses an explicit catalog selection for %s",
async (client, { cli }) => {
const home = await createTestTempDirectory("rea-setup-catalog-");
const config = join(
home,
client === "codex" ? ".codex/config.toml" : ".cursor/mcp.json",
);
const original =
client === "codex"
? 'model = "caller-choice"\n[features]\nmcp_tool_list_changed = false\n'
: JSON.stringify({ caller_choice: true });
await mkdir(dirname(config), { recursive: true });
await writeFile(config, original);
const environment = {
HOME: home,
USERPROFILE: home,
CODEX_HOME: join(home, ".codex"),
XDG_CONFIG_HOME: join(home, ".config"),
XDG_DATA_HOME: join(home, ".local/share"),
XDG_CACHE_HOME: join(home, ".cache"),
REA_MCP_TOOL_CATALOG_PROFILE: undefined,
};
const setup = (approval: string, profile?: string) =>
cli.run({
arguments: [
"setup",
"--client",
client,
"--no-skill",
approval,
"--json",
...(profile === undefined ? [] : ["--tool-catalog-profile", profile]),
],
cwd: home,
environment,
});
const parse = (text: string) =>
client === "codex" ? parseToml(text) : JSON.parse(text);
const setting =
client === "codex"
? "mcp_servers.rea.env.REA_MCP_TOOL_CATALOG_PROFILE"
: "mcpServers.rea.env.REA_MCP_TOOL_CATALOG_PROFILE";
const backup = `${config}.rea.backup`;
const planned = await setup("--dry-run", "hot-cold");
expect(planned.exitCode, planned.stderr).toBe(0);
expect(planned.json).toMatchObject({
status: "planned",
plannedActions: [
expect.objectContaining({
detail: expect.stringContaining(
"REA_MCP_TOOL_CATALOG_PROFILE=hot-cold",
),
}),
],
});
expect(await readFile(config, "utf8")).toBe(original);
await expect(access(backup)).rejects.toMatchObject({ code: "ENOENT" });
const applied = await setup("--yes", "hot-cold");
expect(applied.exitCode, applied.stderr).toBe(0);
const saved = await readFile(config, "utf8");
expect(parse(saved)).toHaveProperty(setting, "hot-cold");
expect(parse(saved)).toMatchObject(parse(original));
expect(await readFile(backup, "utf8")).toBe(original);
for (const profile of ["hot-cold", undefined]) {
const repeated = await setup("--yes", profile);
expect(repeated.exitCode, repeated.stderr).toBe(0);
expect(repeated.json).toMatchObject({
status: "ready",
plannedActions: [],
appliedActions: [],
});
expect(await readFile(config, "utf8")).toBe(saved);
}
const restored = await setup("--yes", "full");
expect(restored.exitCode, restored.stderr).toBe(0);
const full = await readFile(config, "utf8");
expect(parse(full)).toHaveProperty(setting, "full");
expect(parse(full)).toMatchObject(parse(original));
const files = await readdir(dirname(config));
const invalid = await setup("--yes", "auto");
expect(invalid.exitCode).not.toBe(0);
expect(invalid.json).toMatchObject({ code: "VALIDATION_ERROR" });
expect(await readFile(config, "utf8")).toBe(full);
expect(await readdir(dirname(config))).toEqual(files);
},
);
cliTest.for(["codex", "cursor"] as const)(
"keeps the full catalog by default for %s without a verified refresh selection",
async (client, { cli }) => {
const home = await createTestTempDirectory("rea-setup-default-catalog-");
const result = await cli.run({
arguments: ["setup", "--client", client, "--no-skill", "--yes", "--json"],
cwd: home,
environment: {
HOME: home,
USERPROFILE: home,
CODEX_HOME: join(home, ".codex"),
XDG_CONFIG_HOME: join(home, ".config"),
XDG_DATA_HOME: join(home, ".local/share"),
XDG_CACHE_HOME: join(home, ".cache"),
REA_MCP_TOOL_CATALOG_PROFILE: undefined,
},
});
expect(result.exitCode, result.stderr).toBe(0);
const path = join(
home,
client === "codex" ? ".codex/config.toml" : ".cursor/mcp.json",
);
const text = await readFile(path, "utf8");
const parsed = client === "codex" ? parseToml(text) : JSON.parse(text);
expect(parsed).not.toHaveProperty(
client === "codex"
? "mcp_servers.rea.env.REA_MCP_TOOL_CATALOG_PROFILE"
: "mcpServers.rea.env.REA_MCP_TOOL_CATALOG_PROFILE",
);
},
);