From 187f113f798b5b8a080d22285b51c05596c3e0f2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:00:18 +0800 Subject: [PATCH] fix(identity)!: preserve invocation uncertainty and advertised catalogs Capture the current entrypoint once without inventing a path. Preserve unknown registration ownership through setup and doctor, refuse unsafe registration plans, and retain exact pinned-launcher readiness checks. Hash the same full or compact schema projection advertised by each MCP server and use that identity in doctor comparisons. BREAKING CHANGE: server.command_path may be null when the invocation path is unavailable. Registration and setup statuses, and doctor classifications, may report unknown. Reviewed-PR: #3230, #3015 Co-authored-by: qorvr <330186599+qorvr@users.noreply.github.com> Co-authored-by: Hemang Varshney <218273197+hemv-857@users.noreply.github.com> --- src/application/ClientRegistrationIdentity.ts | 52 +++++++- src/application/ClientRegistrationStatus.ts | 113 +++++++++++------- src/application/DirectAnalysisStatus.ts | 2 + src/application/Doctor.test.ts | 8 +- src/application/Doctor.ts | 3 +- src/application/DoctorScope.ts | 2 +- src/application/Setup.configuration.test.ts | 15 +-- src/application/Setup.fixture.ts | 9 +- src/application/Setup.planning.test.ts | 49 ++++++++ src/application/SetupHost.ts | 35 +++--- src/application/SetupPlan.ts | 41 +++++-- src/application/SetupTypes.ts | 9 +- src/application/Uninstall.ts | 8 +- src/catalogIdentity.ts | 52 +++++--- src/config/mcpInputSchemaProfile.ts | 8 ++ src/contracts/compactInputSchema.ts | 15 +++ src/contracts/toolOutputSchemaPrimitives.ts | 2 +- src/main.ts | 4 +- src/mcpDoctor.ts | 27 ++++- src/process/CurrentEntrypointPath.ts | 11 ++ src/server/EvidenceMcpServer.ts | 30 +++-- src/server/createServer.ts | 6 +- src/server/registerSessionStatusTool.ts | 3 + src/serverIdentity.ts | 30 +++-- .../clientConfigurationDialects.test.ts | 95 ++++++++++++--- .../clientRegistrationStatus.test.ts | 94 ++++++++++++++- .../kimiClientConfiguration.test.ts | 2 +- .../filesystem/windowsNpxRegistration.test.ts | 6 +- .../mcp/compactInputSchemaMcp.test.ts | 66 ++++++++++ tests/boundary/mcp/mcpDoctor.test.ts | 56 +++++---- tests/boundary/mcp/serverIdentity.test.ts | 66 +++++++++- .../contracts/mcpInputSchemaProfile.test.ts | 16 ++- 32 files changed, 755 insertions(+), 180 deletions(-) create mode 100644 src/process/CurrentEntrypointPath.ts diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index bdc94297e4..0c35bebd79 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -1,4 +1,4 @@ -import { basename, isAbsolute, resolve } from "node:path"; +import { isAbsolute, resolve } from "node:path"; import { valid } from "semver"; import { PRODUCT_IDENTITY } from "../identity.js"; @@ -112,13 +112,56 @@ const isOwnedPackageReference = (packageReference: string): boolean => { }; /** Check that one parsed command points to REA's MCP entry point. */ +export type ClientRegistrationCommandIdentity = "owned" | "foreign" | "unknown"; + +/** Classify only what the command and current executable identity establish. */ +export const classifyClientRegistrationCommand = ( + command: readonly string[], + currentCommandPath: string | undefined, +): ClientRegistrationCommandIdentity => { + const packageReference = clientRegistrationPackageReference(command); + if (packageReference !== undefined) + return isOwnedClientRegistrationCommand(command, currentCommandPath) + ? "owned" + : "foreign"; + const currentPathAvailable = + currentCommandPath !== undefined && currentCommandPath.length > 0; + const nodeScript = command.length === 3 && command[2] === "mcp"; + if (nodeScript) { + if ( + !isAbsolute(command[0] ?? "") || + !isAbsolute(command[1] ?? "") || + !currentPathAvailable + ) + return "unknown"; + return isOwnedClientRegistrationCommand(command, currentCommandPath) + ? "owned" + : "foreign"; + } + if (command.length === 2 && command[1] === "mcp") { + const executable = command[0] ?? ""; + if (executable === PRODUCT_IDENTITY.cliBinary) return "owned"; + const relativePathLike = + !isAbsolute(executable) && /[./\\]/u.test(executable); + if (relativePathLike || (isAbsolute(executable) && !currentPathAvailable)) + return "unknown"; + } + if (isOwnedClientRegistrationCommand(command, currentCommandPath)) + return "owned"; + return "foreign"; +}; + export const isOwnedClientRegistrationCommand = ( command: readonly string[], - currentCommandPath: string = resolve(process.argv[1] ?? "unknown"), + currentCommandPath: string | undefined, ): boolean => { if ( + currentCommandPath !== undefined && + currentCommandPath.length > 0 && command.length === 3 && command[2] === "mcp" && + isAbsolute(command[0] ?? "") && + isAbsolute(command[1] ?? "") && resolve(command[0] ?? "") === resolve(process.execPath) && resolve(command[1] ?? "") === currentCommandPath ) @@ -129,8 +172,9 @@ export const isOwnedClientRegistrationCommand = ( if ( executable === PRODUCT_IDENTITY.cliBinary || (isAbsolute(executable) && - basename(executable) === PRODUCT_IDENTITY.cliBinary) || - resolve(executable) === currentCommandPath + currentCommandPath !== undefined && + currentCommandPath.length > 0 && + resolve(executable) === currentCommandPath) ) return true; } diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 0afbf59686..0fc37ff135 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -7,16 +7,17 @@ import { type ClientConfigurationDocument, } from "./ClientConfigurationDocument.js"; import { access } from "node:fs/promises"; -import { resolve } from "node:path"; +import { isAbsolute, resolve } from "node:path"; import { z } from "zod"; import { valid } from "semver"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js"; import { clientRegistrationPackageReference, - isOwnedClientRegistrationCommand, + classifyClientRegistrationCommand, packageRunnerRegistrationCommand, } from "./ClientRegistrationIdentity.js"; import { @@ -50,6 +51,11 @@ export type ClientRegistrationStatus = ClientRegistrationStatusBase & readonly state: "stale"; readonly remediation: string; } + | { + readonly command: RegistrationCommand; + readonly state: "unknown"; + readonly remediation: string; + } | { readonly command: readonly []; readonly state: "missing" | "invalid" | "manual"; @@ -93,7 +99,7 @@ export interface ClientRegistrationInspection { /** Project diagnostics from the same inspection used to select maintenance. */ export const readClientRegistrationStatuses = async ( home: string, - currentCommandPath: string = resolve(process.argv[1] ?? "unknown"), + currentCommandPath: string | undefined = CURRENT_ENTRYPOINT_PATH, options: ClientRegistrationReadOptions = {}, ): Promise => ( @@ -103,7 +109,7 @@ export const readClientRegistrationStatuses = async ( /** Parse effective registrations and their client-specific activation once. */ export const readClientRegistrationInspections = async ( home: string, - currentCommandPath: string = resolve(process.argv[1] ?? "unknown"), + currentCommandPath: string | undefined = CURRENT_ENTRYPOINT_PATH, options: ClientRegistrationReadOptions = {}, ): Promise => { const inspections: ClientRegistrationInspection[] = []; @@ -219,26 +225,32 @@ export const readClientRegistrationInspections = async ( const packageVersionDrift = packageVersion !== undefined && packageVersion !== PRODUCT_IDENTITY.packageVersion; + const alignment = registrationAligned( + registration, + effectiveClient, + currentCommandPath, + options.platform ?? process.platform, + ); record( configuredStatus( effectiveClient, command, - activation === "enabled" && - !packageVersionDrift && - registrationAligned( - registration, - effectiveClient, - currentCommandPath, - options.platform ?? process.platform, - ) && - (parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) && - policyBlock === undefined - ? "aligned" - : "stale", + alignment === "unknown" + ? "unknown" + : activation === "enabled" && + !packageVersionDrift && + alignment === "aligned" && + (parsed.dialect !== "dsh" || + dshDocument(parsed.document).ready) && + policyBlock === undefined + ? "aligned" + : "stale", policyBlock ?? (packageVersionDrift ? packageVersionRemediation(packageReference ?? "", client) - : undefined), + : alignment === "unknown" + ? unknownEntryPointRemediation() + : undefined), packageReference, ), activation, @@ -275,25 +287,29 @@ const registrationActivation = ( const registrationAligned = ( registration: z.output, client: SetupClient, - currentCommandPath: string, + currentCommandPath: string | undefined, platform: NodeJS.Platform, -): boolean => { +): "aligned" | "stale" | "unknown" => { const command = [registration.command, ...registration.args]; - if (!isOwnedClientRegistrationCommand(command, currentCommandPath)) - return false; + const identity = classifyClientRegistrationCommand( + command, + currentCommandPath, + ); + if (identity === "unknown") return "unknown"; + if (identity === "foreign") return "stale"; if ( (client.name === "codex" || client.name === "grok_build") && registration.startup_timeout_sec !== MCP_STARTUP_POLICY.codexStartupTimeoutSeconds ) - return false; + return "stale"; if ( client.name === "kimi" && (registration.startupTimeoutMs !== MCP_STARTUP_POLICY.kimiStartupTimeoutMs || registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs) ) - return false; + return "stale"; if ( (client.format === "kimi" || client.format === "omp" || @@ -301,45 +317,55 @@ const registrationAligned = ( registration.type !== undefined && registration.type !== "stdio" ) - return false; - if (client.format === "vscode" && registration.type !== "stdio") return false; + return "stale"; + if (client.format === "vscode" && registration.type !== "stdio") + return "stale"; // A Harness registration only connects as the rea stdio server; another // server name or transport leaves the session without REA tools. if ( client.format === "dsh" && (registration.serverName !== "rea" || registration.transport !== "stdio") ) - return false; + return "stale"; if ( client.format === "copilot_cli" && (registration.type !== "stdio" || JSON.stringify(registration.tools) !== JSON.stringify(["*"])) ) - return false; + return "stale"; if (client.format === "commandcode" && registration.transport !== "stdio") - return false; - if ( - command.length === 3 && - command[2] === "mcp" && - resolve(command[0] ?? "") === resolve(process.execPath) && - resolve(command[1] ?? "") === currentCommandPath - ) - return true; + return "stale"; if ( (["npx", "bunx"] as const).some((runner) => { - const runnerCommand = packageRunnerRegistrationCommand(runner, platform); + const expected = packageRunnerRegistrationCommand(runner, platform); return ( - command.length === runnerCommand.length && - command.every((argument, index) => argument === runnerCommand[index]) + command.length === expected.length && + command.every((argument, index) => argument === expected[index]) ); }) ) - return true; - return ( + return "aligned"; + if ( + currentCommandPath !== undefined && + currentCommandPath.length > 0 && + command.length === 3 && + command[2] === "mcp" && + isAbsolute(command[0] ?? "") && + isAbsolute(command[1] ?? "") && + resolve(command[0] ?? "") === resolve(process.execPath) && + resolve(command[1] ?? "") === currentCommandPath + ) + return "aligned"; + if ( + currentCommandPath !== undefined && + currentCommandPath.length > 0 && command.length === 2 && command[1] === "mcp" && + isAbsolute(command[0] ?? "") && resolve(command[0] ?? "") === currentCommandPath - ); + ) + return "aligned"; + return "stale"; }; const parseRegistration = ( @@ -400,10 +426,13 @@ const remediation = (client: SetupClient): string => { return `Quit ${name} fully, run rea setup to refresh this registration, then start ${name} again. A running ${name} can rewrite its configuration from memory and drop the registration.`; }; +const unknownEntryPointRemediation = (): string => + "The existing registration was retained because its executable identity cannot be verified. Run the installed REA CLI or a supported package runner, then rerun rea doctor; no registration was changed."; + const configuredStatus = ( client: SetupClient, command: RegistrationCommand, - state: "aligned" | "stale", + state: "aligned" | "stale" | "unknown", policyRemediation?: string, packageReference?: string, ): ClientRegistrationStatus => diff --git a/src/application/DirectAnalysisStatus.ts b/src/application/DirectAnalysisStatus.ts index d67dcc9205..5889987b3c 100644 --- a/src/application/DirectAnalysisStatus.ts +++ b/src/application/DirectAnalysisStatus.ts @@ -7,6 +7,7 @@ import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js import { providerCapabilitySet } from "../contracts/toolOutputSchemaPrimitives.js"; import { providerCapabilitySets } from "./binary/BinarySessionStatus.js"; import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; /** Read target-free provider status and operation availability. */ export const runSessionStatus = async ( @@ -31,6 +32,7 @@ export const runSessionStatus = async ( ], server_identity: createServerIdentity({ startedAt: new Date().toISOString(), + commandPath: CURRENT_ENTRYPOINT_PATH, }), }; } finally { diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 63e0eef19f..0fd444f34b 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -330,7 +330,7 @@ describe("doctor scoped readiness", () => { client: "cursor", config_path: "/home/user/.cursor/mcp.json", command: ["/old/rea", "mcp"], - state: "stale", + state: "unknown", remediation: "Run rea setup to refresh this registration.", }, ]; @@ -349,7 +349,11 @@ describe("doctor scoped readiness", () => { expect.objectContaining({ name: "registration:codex", ok: true }), ); expect(scoped.informational_checks).toContainEqual( - expect.objectContaining({ name: "registration:cursor", ok: false }), + expect.objectContaining({ + name: "registration:cursor", + ok: false, + classification: "unknown", + }), ); const staleSelected = await runDoctor( diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 6659a75a68..f5a6cd4bdb 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -53,6 +53,7 @@ export interface DoctorCheck { | "unsupported_host" | "unsupported_target" | "missing_analysis_engine" + | "unknown" | "config_drift"; readonly detail?: string; readonly details?: Readonly>; @@ -385,7 +386,7 @@ const registrationCheck = ( ): DoctorCheck => ({ name: `registration:${registration.client}`, ok: false, - classification: "config_drift", + classification: registration.state === "unknown" ? "unknown" : "config_drift", detail: registration.config_path, remediation: registration.remediation, }); diff --git a/src/application/DoctorScope.ts b/src/application/DoctorScope.ts index 26838b63e4..da22d43b41 100644 --- a/src/application/DoctorScope.ts +++ b/src/application/DoctorScope.ts @@ -174,7 +174,7 @@ const registrationCheck = ( ): DoctorCheck => ({ name: `registration:${status.client}`, ok: false, - classification: "config_drift", + classification: status.state === "unknown" ? "unknown" : "config_drift", detail: status.config_path, remediation: status.remediation, }); diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index 8fe8e9ee88..274235af11 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { FakeSetupHost, options } from "./Setup.fixture.js"; import { runSetup } from "./Setup.js"; import { setupRegistrationCommand } from "./SetupHost.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; import { discoverSetupState } from "./SetupPlan.js"; import type { MsixPackageView } from "./MsixPackageStorage.js"; import type { SetupClient } from "./SupportedClients.js"; @@ -333,13 +334,13 @@ describe("setup workflow action selection", () => { expect(result.status).toBe("ready"); expect(host.configuredCommands).toEqual([ - setupRegistrationCommand("win32", undefined), - ]); - expect(setupRegistrationCommand("win32", undefined)).toEqual([ - process.execPath, - resolve(process.argv[1] ?? "rea"), - "mcp", + setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH), ]); + expect( + setupRegistrationCommand("win32", undefined, CURRENT_ENTRYPOINT_PATH), + ).toEqual([process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"]); + expect(setupRegistrationCommand("win32", undefined, undefined)).toEqual([]); + expect(setupRegistrationCommand("win32", "npx", undefined)).not.toEqual([]); }); it.each([ diff --git a/src/application/Setup.fixture.ts b/src/application/Setup.fixture.ts index e329b0eefc..b74b4465f3 100644 --- a/src/application/Setup.fixture.ts +++ b/src/application/Setup.fixture.ts @@ -16,6 +16,7 @@ import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js"; import { CATALOG_IDENTITY } from "../catalogIdentity.js"; import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js"; import { setupRegistrationCommand } from "./SetupHost.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; /** Recording setup host for service-level planning and recovery tests. */ export class FakeSetupHost implements SetupHost { @@ -24,7 +25,7 @@ export class FakeSetupHost implements SetupHost { skillDestinations(clientIds: readonly string[]) { return skillDestinations(this.homeDirectory, clientIds); } - readonly registrationCommand: readonly string[]; + registrationCommand: readonly string[]; registrationCommandBlocker?: string; nodeVersion = "24.18.0"; version: string | undefined = "14.5"; @@ -57,7 +58,11 @@ export class FakeSetupHost implements SetupHost { constructor(platform: NodeJS.Platform = "darwin") { this.platform = platform; - this.registrationCommand = setupRegistrationCommand(platform, undefined); + this.registrationCommand = setupRegistrationCommand( + platform, + undefined, + CURRENT_ENTRYPOINT_PATH, + ); } macosVersion = (): Promise => diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index ce52be4697..b0ae6479aa 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -136,6 +136,55 @@ describe("skill plan destinations", () => { }); describe("setup workflow", () => { + it("blocks client registration when no executable entrypoint is available", async () => { + const host = new FakeSetupHost("win32"); + host.registrationCommand = []; + host.registrationCommandBlocker = + "REA could not determine the current executable entrypoint."; + host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }]; + + const result = await runSetup( + { ...options(true), clientIds: ["codex"], installSkill: false }, + host, + ); + + expect(result.status).toBe("needs_human"); + expect(result.plannedActions).toEqual([]); + expect(result.remediation).toBe(host.registrationCommandBlocker); + expect(host.configurations).toBe(0); + }); + + it("keeps a relative Node registration out of setup defaults when cwd happens to match", async () => { + const host = new FakeSetupHost(); + host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.productRegistrations = [ + { + client: "cursor", + config_path: "/cursor.json", + command: [process.execPath, "./rea.mjs", "mcp"], + state: "stale", + remediation: "Run rea setup to refresh this registration.", + }, + ]; + + const result = await runSetup( + { ...options(false), clientIds: [], installSkill: false }, + host, + ); + + expect(result.clientStates).toContainEqual( + expect.objectContaining({ + client: expect.objectContaining({ name: "cursor" }), + status: "unknown", + configured: false, + }), + ); + expect(result.plannedActions).toEqual([]); + expect(host.configurations).toBe(0); + }); +}); + +describe("setup plan application", () => { it("returns a complete plan without mutation", async () => { const host = new FakeSetupHost(); host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 3674cd01fe..520380eaba 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -56,21 +56,21 @@ import type { SetupProviderEnvironment, } from "./SetupTypes.js"; import type { DoctorScope } from "./Doctor.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; /** Resolve the executable and arguments used in a managed MCP registration. */ export const setupRegistrationCommand = ( platform: NodeJS.Platform, packageRunner: PackageRunner | undefined, -): readonly string[] => - packageRunner !== undefined - ? packageRunnerRegistrationCommand(packageRunner, platform) - : platform === "win32" - ? [ - process.execPath, - resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), - "mcp", - ] - : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"]; + entrypointPath: string | undefined, +): readonly string[] => { + if (packageRunner !== undefined) + return packageRunnerRegistrationCommand(packageRunner, platform); + if (entrypointPath === undefined || entrypointPath.length === 0) return []; + return platform === "win32" + ? [process.execPath, entrypointPath, "mcp"] + : [entrypointPath, "mcp"]; +}; /** * The launcher setup registers, and why it must not be registered when it @@ -84,14 +84,17 @@ const setupRegistration = ( const registrationCommand = setupRegistrationCommand( platform, invokingPackageRunner(environment, platform), + CURRENT_ENTRYPOINT_PATH, ); const registrationCommandBlocker = - platform === "win32" - ? packagePrivateLauncherBlocker( - registrationCommand, - msixPackagesDirectory(homeDirectory, environment), - ) - : undefined; + registrationCommand.length === 0 + ? "REA could not determine the current executable entrypoint. Run setup through the installed REA CLI or a supported package runner before configuring clients." + : platform === "win32" + ? packagePrivateLauncherBlocker( + registrationCommand, + msixPackagesDirectory(homeDirectory, environment), + ) + : undefined; return registrationCommandBlocker === undefined ? { registrationCommand } : { registrationCommand, registrationCommandBlocker }; diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 345a05c457..1eb6be56ba 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; -import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { classifyClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { linuxHopperInstallDisclosure, type LinuxPackageFamily, @@ -22,6 +22,7 @@ import { import { redirectedWriteBlocker } from "./MsixPackageStorage.js"; import type { DoctorReport } from "./Doctor.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; /** Read-only setup facts shared by client selection and preflight. */ export interface SetupDiscovery { @@ -78,20 +79,29 @@ export const discoverSetupState = async (input: { ); const clientStates = clients.map((client): SetupClientState => { const registration = registrationByClient.get(client.name); + const commandIdentity = + registration === undefined + ? undefined + : classifyClientRegistrationCommand( + registration.command, + CURRENT_ENTRYPOINT_PATH, + ); const ownedRegistration = (registration?.state === "aligned" || registration?.state === "stale") && - isOwnedClientRegistrationCommand(registration.command); + commandIdentity === "owned"; const configured = ownedRegistration; const status: SetupClientState["status"] = registration?.state === "invalid" ? "invalid" - : ownedRegistration && registration.state === "aligned" - ? "configured" - : ownedRegistration && registration.state === "stale" - ? "needs_configuration" - : detectedIds.has(client.name) + : registration?.state === "unknown" || commandIdentity === "unknown" + ? "unknown" + : ownedRegistration && registration.state === "aligned" + ? "configured" + : ownedRegistration && registration.state === "stale" ? "needs_configuration" - : "missing"; + : detectedIds.has(client.name) + ? "needs_configuration" + : "missing"; return { client, detected: detectedIds.has(client.name), @@ -103,7 +113,10 @@ export const discoverSetupState = async (input: { .filter( (registration) => (registration.state === "aligned" || registration.state === "stale") && - isOwnedClientRegistrationCommand(registration.command), + classifyClientRegistrationCommand( + registration.command, + CURRENT_ENTRYPOINT_PATH, + ) === "owned", ) .map(({ client }) => client); return { @@ -150,6 +163,16 @@ export const planSetupActions = async (input: { const client = byId.get(id); return client === undefined ? [] : [client]; }); + if ( + selectedClients.length > 0 && + input.host.registrationCommandBlocker !== undefined + ) + return { + plannedActions: [], + selectedClients, + clientsToConfigure: [], + blocker: input.host.registrationCommandBlocker, + }; const inspections = await Promise.all( selectedClients.map(async (client) => ({ client, diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index dffc224ec7..63564a69f5 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -67,7 +67,7 @@ export interface SetupHost { readonly homeDirectory: string; skillDestinations(clientIds: readonly string[]): readonly SkillDestination[]; readonly registrationCommand: readonly string[]; - /** Why no client may be registered with `registrationCommand` from this host. */ + /** Why client registration cannot proceed from this host. */ readonly registrationCommandBlocker?: string; readonly nodeVersion: string; macosVersion(): Promise; @@ -157,7 +157,12 @@ export interface SetupClientState { readonly client: SetupClient; readonly detected: boolean; readonly configured: boolean; - readonly status: "configured" | "missing" | "needs_configuration" | "invalid"; + readonly status: + | "configured" + | "missing" + | "needs_configuration" + | "invalid" + | "unknown"; } /** One concrete setup mutation disclosed before approval. */ diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index b1871c2195..2c8b948cc2 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -23,6 +23,7 @@ import { skillDestinations, } from "./SetupSkill.js"; import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; import { dshDocument } from "./DshCordisPatch.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { @@ -562,12 +563,15 @@ const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => { .safeParse(value); if (!parsed.success) return false; const command = parsed.data.command; - return isOwnedClientRegistrationCommand(command); + return isOwnedClientRegistrationCommand(command, CURRENT_ENTRYPOINT_PATH); } const parsed = registrationSchema.safeParse(value); if (!parsed.success) return false; const { command, args } = parsed.data; - return isOwnedClientRegistrationCommand([command, ...args]); + return isOwnedClientRegistrationCommand( + [command, ...args], + CURRENT_ENTRYPOINT_PATH, + ); }; const purgeManagedData = async ( diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index 1d40aad02d..74515ef755 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -10,6 +10,10 @@ import type { ToolContract } from "./contracts/toolContractTypes.js"; import { CLI_COMMAND_NAMES } from "./cliCommandNames.js"; import { compareUnicodeCodePoints } from "./domain/unicodeCodePointOrder.js"; import { advertisedInputJsonSchema } from "./contracts/toolSchemaMetadata.js"; +import { + presentAdvertisedInputSchema, + type CompactInputSchemaPresentation, +} from "./contracts/compactInputSchema.js"; const sortByName = ( contracts: readonly ToolContract[], @@ -26,7 +30,10 @@ const sortedToolContracts = sortByName(TOOL_CONTRACTS); * that predates the untrusted-content framing must not digest equal to one * that emits `_meta.provenance`, or a stale process would report aligned. */ -const createToolCatalog = (contracts: readonly ToolContract[]) => +const createToolCatalog = ( + contracts: readonly ToolContract[], + compactPresentation: CompactInputSchemaPresentation | undefined, +) => contracts.map((contract) => ({ name: contract.name, title: contract.title, @@ -35,7 +42,10 @@ const createToolCatalog = (contracts: readonly ToolContract[]) => effects: { ...contract.effects }, returns_target_content: contract.returnsTargetContent, annotations: contract.annotations, - input_schema: advertisedInputJsonSchema(contract), + input_schema: presentAdvertisedInputSchema( + advertisedInputJsonSchema(contract), + compactPresentation, + ), output_schema: { type: "object", ...z.toJSONSchema(contract.outputSchema, { @@ -73,8 +83,9 @@ const digest = (value: unknown): string => { export const createCatalogDigests = ( contracts: readonly ToolContract[] = TOOL_CONTRACTS, promptContracts: readonly PromptContract[] = PROMPT_CONTRACTS, + compactPresentation: CompactInputSchemaPresentation | undefined = undefined, ) => { - const tools = createToolCatalog(sortByName(contracts)); + const tools = createToolCatalog(sortByName(contracts), compactPresentation); const prompts = createPromptCatalog(promptContracts); return { tools_sha256: digest(tools), @@ -87,16 +98,29 @@ export const createCatalogDigests = ( } as const; }; -let catalogDigests: ReturnType | undefined; +export const createCatalogIdentity = ( + compactPresentation: CompactInputSchemaPresentation | undefined = undefined, +) => { + const selectedPresentation = + compactPresentation === undefined + ? undefined + : { budgetBytes: compactPresentation.budgetBytes }; + let catalogDigests: ReturnType | undefined; + return { + counts: { + cli_commands: CLI_COMMAND_NAMES.length, + mcp_tools: sortedToolContracts.length, + mcp_prompts: promptCatalog.length, + }, + get digests() { + return (catalogDigests ??= createCatalogDigests( + TOOL_CONTRACTS, + PROMPT_CONTRACTS, + selectedPresentation, + )); + }, + } as const; +}; /** Stable, schema-sensitive identity; schema digests are computed on first use. */ -export const CATALOG_IDENTITY = { - counts: { - cli_commands: CLI_COMMAND_NAMES.length, - mcp_tools: sortedToolContracts.length, - mcp_prompts: promptCatalog.length, - }, - get digests() { - return (catalogDigests ??= createCatalogDigests()); - }, -} as const; +export const CATALOG_IDENTITY = createCatalogIdentity(); diff --git a/src/config/mcpInputSchemaProfile.ts b/src/config/mcpInputSchemaProfile.ts index 321803b5b6..473fb0cbea 100644 --- a/src/config/mcpInputSchemaProfile.ts +++ b/src/config/mcpInputSchemaProfile.ts @@ -12,6 +12,14 @@ export const COMPACT_INPUT_SCHEMA_BUDGET_BYTES = 13 * 1024; export const mcpInputSchemaProfileSchema = z.enum(["full", "compact"]); +/** Select the exact compact presentation budget shared by registration and identity. */ +export const compactPresentationForMcpInputSchemaProfile = ( + profile: "full" | "compact" | undefined, +): { readonly budgetBytes: number } | undefined => + profile === "compact" + ? { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES } + : undefined; + const profileConstraint = 'REA_MCP_INPUT_SCHEMA_PROFILE must be "full" (default) or "compact". The compact profile advertises input schemas without nested annotation prose for providers that cap each tool schema\'s serialized size.'; diff --git a/src/contracts/compactInputSchema.ts b/src/contracts/compactInputSchema.ts index f756fdda4c..49585bae3c 100644 --- a/src/contracts/compactInputSchema.ts +++ b/src/contracts/compactInputSchema.ts @@ -26,6 +26,11 @@ const jsonValueTypes = [ export type AdvertisedInputSchemaPresentation = "inline" | "reduced"; +/** Budget selected by the caller for compact MCP input-schema presentation. */ +export interface CompactInputSchemaPresentation { + readonly budgetBytes: number; +} + export interface CompactedInputSchema { readonly schema: Record; readonly presentation: AdvertisedInputSchemaPresentation; @@ -335,3 +340,13 @@ export const compactAdvertisedInputSchema = ( presentation: "reduced", }; }; + +/** Return the JSON schema clients receive for the selected input presentation. */ +export const presentAdvertisedInputSchema = ( + schema: Record, + compactPresentation: CompactInputSchemaPresentation | undefined, +): Record => + compactPresentation === undefined + ? schema + : compactAdvertisedInputSchema(schema, compactPresentation.budgetBytes) + .schema; diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index e63a79ff50..a00a43d10f 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -505,7 +505,7 @@ export const sessionProvider = z name: z.string(), version: z.string(), started_at: z.string(), - command_path: z.string(), + command_path: z.string().nullable(), }), sdk: z.object({ server: z.string(), diff --git a/src/main.ts b/src/main.ts index f2afec3901..0743843ba0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; +import { CURRENT_ENTRYPOINT_PATH } from "./process/CurrentEntrypointPath.js"; import { pathToFileURL } from "node:url"; import { realpathSync } from "node:fs"; @@ -122,7 +123,6 @@ const isDirectEntrypoint = ( } }; -const entryPath = process.argv[1]; -if (isDirectEntrypoint(entryPath, import.meta.url)) { +if (isDirectEntrypoint(CURRENT_ENTRYPOINT_PATH, import.meta.url)) { void runEntrypoint(); } diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 75859eb270..b502b1b1bd 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -5,7 +5,11 @@ import { performance } from "node:perf_hooks"; import { resolve } from "node:path"; import { StringDecoder } from "node:string_decoder"; -import { CATALOG_IDENTITY } from "./catalogIdentity.js"; +import { createCatalogIdentity } from "./catalogIdentity.js"; +import { + compactPresentationForMcpInputSchemaProfile, + parseMcpInputSchemaProfile, +} from "./config/mcpInputSchemaProfile.js"; import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js"; import { TOOL_CONTRACTS } from "./contracts/toolContracts.js"; import { err, ok } from "./domain/result.js"; @@ -75,6 +79,7 @@ interface McpDoctorCheck { /** Diagnose the actual production stdio adapter with one startup-bounded SDK session. */ export const runProductionMcpDoctor = async (options: McpDoctorOptions) => { + const catalog = catalogIdentityForEnvironment(options.environment); const deadline = performance.now() + (options.deadlineMs ?? MCP_STARTUP_POLICY.doctorDeadlineMs); @@ -104,6 +109,7 @@ export const runProductionMcpDoctor = async (options: McpDoctorOptions) => { client, deadline, controller.signal, + catalog, ); } catch (cause: unknown) { result = { @@ -189,6 +195,7 @@ const inspectProductionMcpSession = async ( client: Client, deadline: number, signal: AbortSignal, + catalog: ReturnType, ) => { const request = requestOptions(deadline, signal); const [tools, prompts] = await Promise.all([ @@ -238,8 +245,7 @@ const inspectProductionMcpSession = async ( name: "server-identity", ok: observedIdentity.packageVersion === PRODUCT_IDENTITY.packageVersion && - observedIdentity.catalogDigest === - CATALOG_IDENTITY.digests.combined_sha256, + observedIdentity.catalogDigest === catalog.digests.combined_sha256, detail: `package=${observedIdentity.packageVersion ?? "missing"}; catalog=${observedIdentity.catalogDigest ?? "missing"}`, }, { @@ -262,7 +268,7 @@ const inspectProductionMcpSession = async ( identity: { expected_package_version: PRODUCT_IDENTITY.packageVersion, observed_package_version: observedIdentity.packageVersion, - expected_catalog_digest: CATALOG_IDENTITY.digests.combined_sha256, + expected_catalog_digest: catalog.digests.combined_sha256, observed_catalog_digest: observedIdentity.catalogDigest, }, request_flow: { @@ -483,6 +489,19 @@ const targetFreeEnvironment = ( return result; }; +const catalogIdentityForEnvironment = ( + environment: Readonly>, +): ReturnType => { + const parsed = parseMcpInputSchemaProfile( + environment.REA_MCP_INPUT_SCHEMA_PROFILE, + ); + return createCatalogIdentity( + compactPresentationForMcpInputSchemaProfile( + parsed.ok ? parsed.value : undefined, + ), + ); +}; + const captureStderr = () => { const retained = Buffer.allocUnsafe(MCP_STARTUP_POLICY.doctorStderrBytes); let retainedBytes = 0; diff --git a/src/process/CurrentEntrypointPath.ts b/src/process/CurrentEntrypointPath.ts new file mode 100644 index 0000000000..8005e1aab0 --- /dev/null +++ b/src/process/CurrentEntrypointPath.ts @@ -0,0 +1,11 @@ +import { resolve } from "node:path"; + +/** Absolute argv entrypoint, or undefined when the process did not supply one. */ +export const normalizeEntrypointPath = ( + entrypoint: string | undefined, +): string | undefined => + entrypoint === undefined || entrypoint.length === 0 + ? undefined + : resolve(entrypoint); + +export const CURRENT_ENTRYPOINT_PATH = normalizeEntrypointPath(process.argv[1]); diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 28618adce7..96c7b58b70 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -21,7 +21,11 @@ import { } from "@modelcontextprotocol/server"; import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; -import { compactAdvertisedInputSchema } from "../contracts/compactInputSchema.js"; +import { + presentAdvertisedInputSchema, + type CompactInputSchemaPresentation, +} from "../contracts/compactInputSchema.js"; +import { CATALOG_IDENTITY, createCatalogIdentity } from "../catalogIdentity.js"; import { transformAdvertisedInputJsonSchema } from "../contracts/toolSchemaMetadata.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; @@ -36,10 +40,7 @@ import { TARGET_CONTENT_RESULT_META, } from "./toolResultEncoding.js"; -/** Compact advertised input schemas within this per-schema byte budget. */ -export interface CompactInputSchemaPresentation { - readonly budgetBytes: number; -} +export type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; const currentRequestSignal = new AsyncLocalStorage(); @@ -103,6 +104,11 @@ export const forgetPendingMcpRequest = ( /** Bind oversized MCP error recovery to this server's Evidence ledger. */ export class EvidenceMcpServer extends McpServer { + readonly catalogIdentity: ReturnType; + private readonly compactPresentation: + | CompactInputSchemaPresentation + | undefined; + constructor( info: Implementation, options: McpServerOptions, @@ -110,12 +116,18 @@ export class EvidenceMcpServer extends McpServer { | EvidenceWriter["recordEvidence"] | undefined, readonly delivery: ToolResultDelivery, - private readonly compactPresentation: - | CompactInputSchemaPresentation - | undefined = undefined, + compactPresentation: CompactInputSchemaPresentation | undefined = undefined, private readonly omitOutputSchemas = false, ) { super(info, options); + this.compactPresentation = + compactPresentation === undefined + ? undefined + : { budgetBytes: compactPresentation.budgetBytes }; + this.catalogIdentity = + this.compactPresentation === undefined + ? CATALOG_IDENTITY + : createCatalogIdentity(this.compactPresentation); } /** @@ -152,7 +164,7 @@ export class EvidenceMcpServer extends McpServer { typeof transformAdvertisedInputJsonSchema >[0], (projected) => - compactAdvertisedInputSchema(projected, budget).schema, + presentAdvertisedInputSchema(projected, { budgetBytes: budget }), ) : undefined; const registeredConfig = diff --git a/src/server/createServer.ts b/src/server/createServer.ts index ce0873dd32..4a018306ea 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -43,7 +43,7 @@ import { import { parseMcpResponseBudget } from "../config/mcpResponseBudget.js"; import { parseMcpOutputSchemaProfile } from "../config/mcpOutputSchemaProfile.js"; import { - COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + compactPresentationForMcpInputSchemaProfile, parseMcpInputSchemaProfile, } from "../config/mcpInputSchemaProfile.js"; import type { CompactInputSchemaPresentation } from "./EvidenceMcpServer.js"; @@ -612,9 +612,7 @@ const selectCompactInputSchemaPresentation = ( environment.REA_MCP_INPUT_SCHEMA_PROFILE, ); if (!configured.ok) throw configured.error; - return configured.value === "compact" - ? { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES } - : undefined; + return compactPresentationForMcpInputSchemaProfile(configured.value); }; const registerConfiguredAnalysisTools = ( diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index 563e7528b9..d0f4d2580f 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -18,6 +18,7 @@ import { mcpClientMetadata } from "./mcpClientMetadata.js"; import type { AvailabilityPolicy } from "../application/CapabilityInventory.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { err } from "../domain/result.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; /** Inputs required to register the binary session status tool. */ export interface SessionStatusToolOptions { @@ -75,6 +76,8 @@ export const registerSessionStatusTool = ( ); const serverIdentity = createServerIdentity({ startedAt, + commandPath: CURRENT_ENTRYPOINT_PATH, + catalog: server.catalogIdentity, expected: { ...(input.expected_package_version === undefined ? {} diff --git a/src/serverIdentity.ts b/src/serverIdentity.ts index 444a877edf..12d88b5f47 100644 --- a/src/serverIdentity.ts +++ b/src/serverIdentity.ts @@ -1,7 +1,10 @@ import { dirname, basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { CATALOG_IDENTITY } from "./catalogIdentity.js"; +import { + CATALOG_IDENTITY, + type createCatalogIdentity, +} from "./catalogIdentity.js"; import { PRODUCT_IDENTITY, SDK_IDENTITY } from "./identity.js"; /** Optional observations supplied by a client comparing its expected server. */ @@ -27,11 +30,14 @@ const packageRoot = (): string => { /** Build one runtime identity without claiming alignment from absent observations. */ export const createServerIdentity = (input: { readonly startedAt: string; + readonly commandPath: string | undefined; readonly expected?: ExpectedServerIdentity; readonly client?: ConnectedClientIdentity; readonly protocolVersion?: string | undefined; + readonly catalog?: ReturnType; }) => { - const serverPath = resolve(process.argv[1] ?? "unknown"); + const catalog = input.catalog ?? CATALOG_IDENTITY; + const serverPath = input.commandPath || undefined; const reasons: string[] = []; if ( input.expected?.package_version !== undefined && @@ -40,17 +46,20 @@ export const createServerIdentity = (input: { reasons.push("package_version_mismatch"); if ( input.expected?.catalog_digest !== undefined && - input.expected.catalog_digest !== CATALOG_IDENTITY.digests.combined_sha256 + input.expected.catalog_digest !== catalog.digests.combined_sha256 ) reasons.push("catalog_digest_mismatch"); if ( input.expected?.server_path !== undefined && + serverPath !== undefined && resolve(input.expected.server_path) !== serverPath ) reasons.push("registration_path_mismatch"); const observedExpectation = input.expected !== undefined && Object.values(input.expected).some((value) => value !== undefined); + const pathComparable = + input.expected?.server_path === undefined || serverPath !== undefined; return { package: { name: PRODUCT_IDENTITY.packageName, @@ -62,7 +71,7 @@ export const createServerIdentity = (input: { name: PRODUCT_IDENTITY.mcpServerKey, version: PRODUCT_IDENTITY.packageVersion, started_at: input.startedAt, - command_path: serverPath, + command_path: serverPath ?? null, }, sdk: SDK_IDENTITY, negotiated_protocol_version: input.protocolVersion ?? null, @@ -75,8 +84,8 @@ export const createServerIdentity = (input: { expected_version: PRODUCT_IDENTITY.skillVersion, }, catalog: { - counts: CATALOG_IDENTITY.counts, - digests: CATALOG_IDENTITY.digests, + counts: catalog.counts, + digests: catalog.digests, }, protocol_features: { progress: true, @@ -87,16 +96,19 @@ export const createServerIdentity = (input: { state: reasons.length > 0 ? "mcp_server_restart_required" - : observedExpectation + : observedExpectation && pathComparable ? "aligned" : "unknown", reasons, remediation: reasons.length > 0 ? "Restart the registered MCP server/client, then compare identity again." - : observedExpectation + : observedExpectation && pathComparable ? null - : "Supply expected package, catalog, or registration identity to compare the live server.", + : input.expected?.server_path !== undefined && + serverPath === undefined + ? "The server entrypoint path is unavailable, so registration identity cannot be compared." + : "Supply expected package, catalog, or registration identity to compare the live server.", }, } as const; }; diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts index ce867aab4d..e4955a6aca 100644 --- a/tests/boundary/filesystem/clientConfigurationDialects.test.ts +++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts @@ -1,5 +1,5 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, relative, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parse as parseJsonc, type ParseError } from "jsonc-parser"; @@ -9,7 +9,14 @@ import { configureClientConfiguration } from "../../../src/application/SetupClie import { systemUninstallHost } from "../../../src/application/Uninstall.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; -import { isOwnedClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { + classifyClientRegistrationCommand, + isOwnedClientRegistrationCommand, +} from "../../../src/application/ClientRegistrationIdentity.js"; +import { + CURRENT_ENTRYPOINT_PATH, + normalizeEntrypointPath, +} from "../../../src/process/CurrentEntrypointPath.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { clearClientLocationEnvironment, @@ -37,6 +44,39 @@ const getClients = (home: string) => ); describe("additional client configuration dialects", () => { + it("does not uninstall a Node registration whose relative script only matches this process cwd", async () => { + if (CURRENT_ENTRYPOINT_PATH === undefined) + throw new Error("test runner entrypoint is unavailable"); + const home = await createTestTempDirectory("rea-relative-uninstall-"); + const cursor = supportedClients(home).find(({ name }) => name === "cursor"); + if (cursor === undefined) throw new Error("missing Cursor client"); + await mkdir(cursor.markerPath ?? home, { recursive: true }); + const relativeScript = relative(process.cwd(), CURRENT_ENTRYPOINT_PATH); + const registration = { + command: process.execPath, + args: [relativeScript, "mcp"], + }; + await writeFile( + cursor.configPath, + JSON.stringify({ mcpServers: { rea: registration } }), + ); + + expect( + await readClientRegistrationStatuses(home, CURRENT_ENTRYPOINT_PATH), + ).toContainEqual( + expect.objectContaining({ + client: "cursor", + command: [process.execPath, relativeScript, "mcp"], + state: "unknown", + }), + ); + await systemUninstallHost(home).removeClient(cursor); + const after = parseJsonc(await readFile(cursor.configPath, "utf8")) as { + readonly mcpServers: Readonly>; + }; + expect(after.mcpServers.rea).toEqual(registration); + }); + it("treats an empty XDG config home as unset for OpenCode setup", async () => { const home = await createTestTempDirectory("rea-opencode-empty-xdg-"); const client = supportedClients(home, "linux", { @@ -130,7 +170,9 @@ describe("additional client configuration dialects", () => { ], ).toBeUndefined(); }); +}); +describe("OpenCode JSONC registration edits", () => { it("preserves OpenCode JSONC comments and sibling registrations during update and removal", async () => { const home = await createTestTempDirectory("rea-opencode-jsonc-"); const client = getClients(home).find(({ name }) => name === "opencode"); @@ -489,28 +531,43 @@ describe("platform-aware client config paths", () => { ]), ); }); +}); +describe("client registration command ownership", () => { it("recognizes prior exact REA releases while rejecting foreign package commands", () => { + expect(isOwnedClientRegistrationCommand(["rea", "mcp"], undefined)).toBe( + true, + ); + const cwdMatchingEntry = resolve("./rea"); expect( - isOwnedClientRegistrationCommand([ - "npx", - "-y", - `${PRODUCT_IDENTITY.packageName}@3.2.0`, - "mcp", - ]), - ).toBe(true); + classifyClientRegistrationCommand(["./rea", "mcp"], cwdMatchingEntry), + ).toBe("unknown"); expect( - isOwnedClientRegistrationCommand(["npx", "-y", "another-package", "mcp"]), + isOwnedClientRegistrationCommand(["./rea", "mcp"], cwdMatchingEntry), ).toBe(false); expect( - isOwnedClientRegistrationCommand([ - "bunx", - `${PRODUCT_IDENTITY.packageName}@3.2.0`, - "mcp", - ]), + isOwnedClientRegistrationCommand( + ["npx", "-y", `${PRODUCT_IDENTITY.packageName}@3.2.0`, "mcp"], + undefined, + ), ).toBe(true); expect( - isOwnedClientRegistrationCommand(["bunx", "another-package", "mcp"]), + isOwnedClientRegistrationCommand( + ["npx", "-y", "another-package", "mcp"], + undefined, + ), + ).toBe(false); + expect( + isOwnedClientRegistrationCommand( + ["bunx", `${PRODUCT_IDENTITY.packageName}@3.2.0`, "mcp"], + undefined, + ), + ).toBe(true); + expect( + isOwnedClientRegistrationCommand( + ["bunx", "another-package", "mcp"], + undefined, + ), ).toBe(false); expect( isOwnedClientRegistrationCommand( @@ -518,6 +575,12 @@ describe("platform-aware client config paths", () => { "/packaged/rea.mjs", ), ).toBe(true); + expect( + isOwnedClientRegistrationCommand( + [process.execPath, resolve("unknown"), "mcp"], + normalizeEntrypointPath(""), + ), + ).toBe(false); expect( isOwnedClientRegistrationCommand( ["node", "/packaged/other-agent.mjs", "mcp"], diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts index 13aca1826a..8ee7417e40 100644 --- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts +++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts @@ -1,5 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { join, relative, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -112,7 +112,9 @@ describe("client registration status", () => { ]); }, ); +}); +describe("client registration launcher identity", () => { it("reports an unversioned npx registration as stale", async () => { const home = await createTestTempDirectory("rea-registrations-"); await mkdir(join(home, ".codex")); @@ -132,6 +134,96 @@ describe("client registration status", () => { ]); }); + it("reports an unpinned bunx registration as stale rather than aligned", async () => { + const home = await createTestTempDirectory("rea-bunx-unpinned-"); + await writeFile( + join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + rea: { command: "bunx", args: ["rea-agents", "mcp"] }, + }, + }), + ); + + const [status] = await readClientRegistrationStatuses(home); + + expect(status).toMatchObject({ + client: "claude_code", + command: ["bunx", "rea-agents", "mcp"], + state: "stale", + }); + }); + + it("preserves direct command identity as unknown when the current entrypoint is absent", async () => { + const home = await createTestTempDirectory("rea-registration-unknown-"); + await mkdir(join(home, ".cursor")); + const command = ["/opt/rea/bin/rea", "mcp"]; + await writeFile( + join(home, ".cursor/mcp.json"), + JSON.stringify({ + mcpServers: { rea: { command: command[0], args: [command[1]] } }, + }), + ); + + const [status] = await readClientRegistrationStatuses(home, ""); + + expect(status).toMatchObject({ + client: "cursor", + command, + state: "unknown", + remediation: expect.stringContaining("registration was retained"), + }); + }); + + it("does not use the REA process cwd to resolve a client's relative launcher path", async () => { + const home = await createTestTempDirectory("rea-registration-relative-"); + await mkdir(join(home, ".cursor")); + const currentEntry = resolve("./rea.mjs"); + const configPath = join(home, ".cursor/mcp.json"); + const registration = ( + scriptPath: string, + executable = process.execPath, + ) => ({ + mcpServers: { + rea: { command: executable, args: [scriptPath, "mcp"] }, + }, + }); + await writeFile(configPath, JSON.stringify(registration("./rea.mjs"))); + + const [relativeStatus] = await readClientRegistrationStatuses( + home, + currentEntry, + ); + expect(relativeStatus).toMatchObject({ + command: [process.execPath, "./rea.mjs", "mcp"], + state: "unknown", + }); + + await writeFile(configPath, JSON.stringify(registration(currentEntry))); + const [absoluteStatus] = await readClientRegistrationStatuses( + home, + currentEntry, + ); + expect(absoluteStatus).toMatchObject({ + command: [process.execPath, currentEntry, "mcp"], + state: "aligned", + }); + + const relativeInterpreter = relative(process.cwd(), process.execPath); + await writeFile( + configPath, + JSON.stringify(registration(currentEntry, relativeInterpreter)), + ); + const [relativeInterpreterStatus] = await readClientRegistrationStatuses( + home, + currentEntry, + ); + expect(relativeInterpreterStatus).toMatchObject({ + command: [relativeInterpreter, currentEntry, "mcp"], + state: "unknown", + }); + }); + it("reports the exact requested package version when it differs from this CLI", async () => { const home = await createTestTempDirectory( "rea-registration-version-drift-", diff --git a/tests/boundary/filesystem/kimiClientConfiguration.test.ts b/tests/boundary/filesystem/kimiClientConfiguration.test.ts index e0580031aa..0ed6397d28 100644 --- a/tests/boundary/filesystem/kimiClientConfiguration.test.ts +++ b/tests/boundary/filesystem/kimiClientConfiguration.test.ts @@ -178,7 +178,7 @@ describe("Kimi Code package launchers", () => { "keeps the %s launcher aligned on %s", async (runner, platform) => { const { home, client } = await kimiClient(); - const launcher = setupRegistrationCommand(platform, runner); + const launcher = setupRegistrationCommand(platform, runner, undefined); expect( (await configureClientConfiguration(client, {}, launcher)).status, ).toBe("configured"); diff --git a/tests/boundary/filesystem/windowsNpxRegistration.test.ts b/tests/boundary/filesystem/windowsNpxRegistration.test.ts index 7aac2f58f3..916ce242bf 100644 --- a/tests/boundary/filesystem/windowsNpxRegistration.test.ts +++ b/tests/boundary/filesystem/windowsNpxRegistration.test.ts @@ -56,7 +56,7 @@ it.each([ expect.objectContaining({ client: "claude_code", state: "stale" }), ); const client = { name: "claude_code", configPath, format: "json" } as const; - const command = setupRegistrationCommand("win32", "npx"); + const command = setupRegistrationCommand("win32", "npx", undefined); expect( await configureClientConfiguration(client, {}, command), ).toMatchObject({ status: "configured" }); @@ -93,7 +93,7 @@ it.each(["npx", "bunx"] as const)( '\uFEFF# Keep this explanation.\nnotify = ["a", "b"]\nliteral = \'C:\\demo\\path\'\ndisabled_mcp_servers = ["rea"]\n\n[mcp_servers.other]\ncommand = "another-server"\n'; await writeFile(configPath, original); const client = { name: "codex", configPath, format: "toml" } as const; - const command = setupRegistrationCommand("win32", runner); + const command = setupRegistrationCommand("win32", runner, undefined); expect( await configureClientConfiguration(client, {}, command), ).toMatchObject({ status: "configured" }); @@ -160,7 +160,7 @@ it.skipIf(process.platform !== "win32").each([ configPath, format: "json", } as const; - const command = setupRegistrationCommand("win32", runner); + const command = setupRegistrationCommand("win32", runner, undefined); // The default writer and npm-run setup must produce the same native launcher. expect( await (runner === "npx" diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 8f8f7ee0c3..6e473c4711 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -4,6 +4,10 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { describe, expect, it } from "vitest"; import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js"; +import { advertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js"; +import { presentAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js"; +import { createCatalogDigests } from "../../../src/catalogIdentity.js"; import { schemaArrays, schemaChildren, @@ -109,6 +113,68 @@ const fullServer = (): EvidenceMcpServer => ); describe("MCP compact input schema profile", () => { + it("matches profile digests to tools/list and excludes stripped annotations", async () => { + const compactPresentation = { + budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + }; + const full = fullServer(); + const compact = compactServer(); + registerCatalog(full); + registerCatalog(compact); + const [fullTools, compactTools] = await Promise.all([ + listToolsThroughTransport(full), + listToolsThroughTransport(compact), + ]); + const toolsByName = [ + new Map(fullTools.map((tool) => [tool.name, tool])), + new Map(compactTools.map((tool) => [tool.name, tool])), + ]; + for (const [index, presentation] of [ + undefined, + compactPresentation, + ].entries()) { + for (const contract of TOOL_CONTRACTS) { + expect(toolsByName[index]?.get(contract.name)?.inputSchema).toEqual( + presentAdvertisedInputSchema( + advertisedInputJsonSchema(contract), + presentation, + ), + ); + } + } + + const fullIdentity = createCatalogDigests(); + const compactIdentity = createCatalogDigests( + TOOL_CONTRACTS, + PROMPT_CONTRACTS, + compactPresentation, + ); + expect(full.catalogIdentity.digests).toEqual(fullIdentity); + expect(compact.catalogIdentity.digests).toEqual(compactIdentity); + expect(compactIdentity.tools_sha256).not.toBe(fullIdentity.tools_sha256); + expect( + createCatalogDigests(TOOL_CONTRACTS, PROMPT_CONTRACTS, { + budgetBytes: 1, + }).tools_sha256, + ).not.toBe(compactIdentity.tools_sha256); + + const withoutExamples = TOOL_CONTRACTS.map((contract) => + contract.examples.length === 0 ? contract : { ...contract, examples: [] }, + ); + expect(createCatalogDigests(withoutExamples).tools_sha256).not.toBe( + fullIdentity.tools_sha256, + ); + expect( + createCatalogDigests( + withoutExamples, + PROMPT_CONTRACTS, + compactPresentation, + ).tools_sha256, + ).toBe(compactIdentity.tools_sha256); + }); +}); + +describe("MCP compact schema constraints", () => { it("advertises every tool input schema within the provider budget", async () => { const server = compactServer(); registerCatalog(server); diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 31afb30ace..3b91a9950f 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -40,34 +40,38 @@ describe("production MCP doctor", () => { }); }); - it("connects to the production stdio child and verifies the canonical catalog", async () => { - const result = await runProductionMcpDoctor({ - command: process.execPath, - args: [resolve("scripts/rea.mjs"), "mcp"], - cwd: process.cwd(), - environment: process.env, - }); + it.each(["full", "compact"] as const)( + "connects to the production stdio child and verifies the %s catalog", + async (profile) => { + const result = await runProductionMcpDoctor({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + environment: { ...process.env, REA_MCP_INPUT_SCHEMA_PROFILE: profile }, + }); - expect(result).toMatchObject({ - healthy: true, - adapter: "production-stdio", - inventory: { - tools: { - expected: expect.any(Number), - observed: expect.any(Number), - missing: [], - unexpected: [], + expect(result).toMatchObject({ + healthy: true, + adapter: "production-stdio", + inventory: { + tools: { + expected: expect.any(Number), + observed: expect.any(Number), + missing: [], + unexpected: [], + }, }, - }, - request_flow: { tool: "binary_session", ok: true }, - }); - expect(result.inventory?.tools.expected).toBe( - result.inventory?.tools.observed, - ); - expect(result.inventory?.tools.expected).toBe( - CATALOG_IDENTITY.counts.mcp_tools, - ); - }, 30_000); + request_flow: { tool: "binary_session", ok: true }, + }); + expect(result.inventory?.tools.expected).toBe( + result.inventory?.tools.observed, + ); + expect(result.inventory?.tools.expected).toBe( + CATALOG_IDENTITY.counts.mcp_tools, + ); + }, + 30_000, + ); }); describe("production MCP doctor probe validation", () => { diff --git a/tests/boundary/mcp/serverIdentity.test.ts b/tests/boundary/mcp/serverIdentity.test.ts index 0a35896fc0..9219405e2e 100644 --- a/tests/boundary/mcp/serverIdentity.test.ts +++ b/tests/boundary/mcp/serverIdentity.test.ts @@ -25,6 +25,7 @@ import { import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js"; import { createServer } from "../../../src/server/createServer.js"; import { createServerIdentity } from "../../../src/serverIdentity.js"; +import { normalizeEntrypointPath } from "../../../src/process/CurrentEntrypointPath.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; import type { @@ -156,19 +157,23 @@ describe("server and catalog identity", () => { it("reports unknown without a live comparison and distinguishes aligned from stale", () => { const unknown = createServerIdentity({ startedAt: "2026-07-13T00:00:00.000Z", + commandPath: normalizeEntrypointPath(""), }); expect(unknown.alignment.state).toBe("unknown"); + expect(unknown.server.command_path).toBeNull(); const aligned = createServerIdentity({ startedAt: "2026-07-13T00:00:00.000Z", + commandPath: "/packaged/rea.mjs", expected: { package_version: PRODUCT_IDENTITY.packageVersion, catalog_digest: CATALOG_IDENTITY.digests.combined_sha256, - server_path: process.argv[1] ?? "unknown", + server_path: "/packaged/rea.mjs", }, }); expect(aligned.alignment).toMatchObject({ state: "aligned", reasons: [] }); const stale = createServerIdentity({ startedAt: "2026-07-13T00:00:00.000Z", + commandPath: "/packaged/rea.mjs", expected: { package_version: "1.2.0", catalog_digest: "0".repeat(64), @@ -180,6 +185,23 @@ describe("server and catalog identity", () => { }); }); + it("keeps registration alignment unknown when the server entrypoint is absent", () => { + const identity = createServerIdentity({ + startedAt: "2026-07-13T00:00:00.000Z", + commandPath: undefined, + expected: { server_path: "/registered/rea.mjs" }, + }); + + expect(identity.server.command_path).toBeNull(); + expect(identity.alignment).toMatchObject({ + state: "unknown", + reasons: [], + remediation: expect.stringContaining("entrypoint path is unavailable"), + }); + }); +}); + +describe("provider capability inventory", () => { it("reports composed, host, and target-specific availability truthfully", () => { const policy = { processCaptureEnabled: true, @@ -305,6 +327,48 @@ describe("live server identity over MCP", () => { ]); } }, 10_000); + + it("compares the caller's digest against the selected compact tools/list catalog", async () => { + const session = createTestBinarySession(availabilityProvider()); + const server = createServer( + { kind: "session", session }, + { environment: { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" } }, + ); + const client = new Client({ name: "compact-identity-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const listed = await client.listTools(); + expect(listed.tools).toHaveLength(TOOL_CONTRACTS.length); + + const response = await client.callTool({ + name: "binary_session", + arguments: { + expected_catalog_digest: + server.catalogIdentity.digests.combined_sha256, + }, + }); + const status = z + .object({ result: sessionProvider }) + .parse(response.structuredContent).result; + expect(status.server_identity.catalog).toEqual(server.catalogIdentity); + expect(status.server_identity.alignment).toMatchObject({ + state: "aligned", + reasons: [], + }); + expect(server.catalogIdentity.digests.tools_sha256).not.toBe( + CATALOG_IDENTITY.digests.tools_sha256, + ); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }, 10_000); }); describe("active-target availability over MCP", () => { diff --git a/tests/conformance/contracts/mcpInputSchemaProfile.test.ts b/tests/conformance/contracts/mcpInputSchemaProfile.test.ts index ababc135e8..661f545087 100644 --- a/tests/conformance/contracts/mcpInputSchemaProfile.test.ts +++ b/tests/conformance/contracts/mcpInputSchemaProfile.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parseMcpInputSchemaProfile } from "../../../src/config/mcpInputSchemaProfile.js"; +import { + compactPresentationForMcpInputSchemaProfile, + COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + parseMcpInputSchemaProfile, +} from "../../../src/config/mcpInputSchemaProfile.js"; describe("MCP input schema profile configuration", () => { it("accepts the full and compact presentations", () => { @@ -26,4 +30,14 @@ describe("MCP input schema profile configuration", () => { expect(parsed.error.message).toContain("REA_MCP_INPUT_SCHEMA_PROFILE"); }, ); + + it("selects the same result-affecting budget for the compact profile", () => { + expect(compactPresentationForMcpInputSchemaProfile(undefined)).toBe( + undefined, + ); + expect(compactPresentationForMcpInputSchemaProfile("full")).toBe(undefined); + expect(compactPresentationForMcpInputSchemaProfile("compact")).toEqual({ + budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES, + }); + }); });