feat(identity): derive MCP surface metadata

This commit is contained in:
morluto 2026-07-14 01:12:05 +00:00
parent 964620a0a1
commit 2722722425
18 changed files with 1099 additions and 70 deletions

33
package-lock.json generated
View File

@ -7,12 +7,13 @@
"": {
"name": "rea-agents",
"version": "1.3.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "8.0.4",
"@babel/types": "8.0.4",
"@electron/asar": "4.2.0",
"@modelcontextprotocol/server": "2.0.0-beta.3",
"@modelcontextprotocol/server": "2.0.0-beta.4",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.0.0",
"@zip.js/zip.js": "2.8.26",
@ -36,7 +37,7 @@
},
"devDependencies": {
"@fast-check/vitest": "0.4.1",
"@modelcontextprotocol/client": "2.0.0-beta.3",
"@modelcontextprotocol/client": "2.0.0-beta.4",
"@types/node": "24.10.1",
"@types/write-file-atomic": "4.0.3",
"@types/ws": "8.18.1",
@ -245,12 +246,13 @@
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.3.tgz",
"integrity": "sha512-o9z9YCGNxWNdklPdjcD0tr8pocC9OgjdArqgx3OjPMlTe0M/SSPC2olOJs5ueq77HSpz36mND0f0KteqEhKF3A==",
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz",
"integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.4",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
@ -262,10 +264,10 @@
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.3.tgz",
"integrity": "sha512-EQxVIWf2XMAgjR38ZRZnX1iiOWH42tT/YJG8N4dVjAnrEGSEfPirR6FYfDC72yuNSvVYbPDTD1H3KHLLHPd18A==",
"node_modules/@modelcontextprotocol/core": {
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz",
"integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==",
"license": "MIT",
"dependencies": {
"zod": "^4.2.0"
@ -274,6 +276,19 @@
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz",
"integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.4",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",

View File

@ -49,6 +49,7 @@
"LICENSE"
],
"scripts": {
"prebuild": "node scripts/generate-package-metadata.mjs",
"build": "tsc -p tsconfig.build.json",
"check": "npm run typecheck && npm run lint && npm run format:check && npm test",
"format": "prettier --write .",
@ -76,7 +77,8 @@
"verify:package": "npm run build && node scripts/verify-package.mjs",
"verify:published": "node scripts/verify-published-package.mjs",
"config:print": "node scripts/print-mcp-config.mjs",
"docs:generate": "typedoc",
"docs:generate": "npm run build && typedoc && node scripts/generate-error-schema.mjs",
"docs:error-schema": "npm run build && node scripts/generate-error-schema.mjs",
"prepack": "npm run build",
"prepare": "husky",
"rebuild:native": "node scripts/rebuild-native.mjs"
@ -89,7 +91,7 @@
"@babel/parser": "8.0.4",
"@babel/types": "8.0.4",
"@electron/asar": "4.2.0",
"@modelcontextprotocol/server": "2.0.0-beta.3",
"@modelcontextprotocol/server": "2.0.0-beta.4",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.0.0",
"@zip.js/zip.js": "2.8.26",
@ -110,7 +112,7 @@
},
"devDependencies": {
"@fast-check/vitest": "0.4.1",
"@modelcontextprotocol/client": "2.0.0-beta.3",
"@modelcontextprotocol/client": "2.0.0-beta.4",
"@types/node": "24.10.1",
"@types/write-file-atomic": "4.0.3",
"@types/ws": "8.18.1",

View File

@ -0,0 +1,35 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
);
const skill = await readFile(
join(root, "skills/rea-analysis/SKILL.md"),
"utf8",
);
const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1];
if (skillVersion === undefined)
throw new Error("Missing rea-analysis skill metadata version");
const versionOf = (name) => {
const value = lock.packages?.[`node_modules/${name}`]?.version;
if (typeof value !== "string")
throw new Error(`Missing locked ${name} version`);
return value;
};
const source = `/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: ${JSON.stringify(packageJson.name)},
version: ${JSON.stringify(packageJson.version)},
serverSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/server"))},
clientSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/client"))},
coreSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/core"))},
skillVersion: ${JSON.stringify(skillVersion)},
} as const;
`;
await writeFile(join(root, "src/generatedPackageMetadata.ts"), source, "utf8");

View File

@ -0,0 +1,193 @@
import { z } from "zod";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import type { JsonValue } from "../domain/jsonValue.js";
const statusSchema = z.object({
open: z.boolean(),
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
format: z.string().optional(),
capabilities: z.array(
z.object({
operation: z.string(),
available: z.boolean(),
reason: z.string().nullable(),
}),
),
});
export type ToolAvailabilityReason =
| "available"
| "target_required"
| "provider_missing"
| "provider_unavailable"
| "target_unsupported"
| "unsupported_host"
| "policy_disabled";
const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
swift_classes: ["list_procedures"],
get_objc_classes: ["list_names"],
get_objc_protocols: ["list_names"],
batch_decompile: ["procedure_pseudo_code"],
get_call_graph: ["procedure_callees", "procedure_callers"],
analyze_swift_types: ["list_procedures"],
find_xrefs_to_name: ["list_names", "xrefs"],
binary_overview: [
"list_segments",
"list_documents",
"list_procedures",
"list_strings",
],
analyze_function: ["analyze_function"],
trace_feature: [
"list_strings",
"list_procedures",
"xrefs",
"resolve_containing_procedure",
],
};
/** Build stable per-operation availability without hiding familiar tools. */
export const buildCapabilityInventory = (
sessionStatus: JsonValue,
policy: {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
},
) => {
const status = statusSchema.parse(sessionStatus);
const descriptors = new Map(
status.capabilities.map((descriptor) => [descriptor.operation, descriptor]),
);
return TOOL_CONTRACTS.map((contract) => {
const availability = availabilityFor(
contract.name,
contract.kind,
status.open,
status.kind,
descriptors,
policy,
);
return {
name: contract.name,
surface: contract.kind,
available: availability.reason === "available",
reason: availability.reason,
remediation: availability.remediation,
annotations: {
read_only: contract.annotations.readOnlyHint ?? false,
destructive: contract.annotations.destructiveHint ?? false,
idempotent: contract.annotations.idempotentHint ?? false,
open_world: contract.annotations.openWorldHint ?? true,
},
};
}).sort((left, right) => left.name.localeCompare(right.name));
};
const availabilityFor = (
name: string,
kind: (typeof TOOL_CONTRACTS)[number]["kind"],
targetOpen: boolean,
targetKind: "executable" | "database" | "archive" | "artifact" | undefined,
descriptors: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
>,
policy: {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
},
): {
readonly reason: ToolAvailabilityReason;
readonly remediation: string | null;
} => {
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
return {
reason: "policy_disabled",
remediation:
"Enable or grant process_capture within the administrator ceiling.",
};
if (name === "import_evidence_bundle" && policy.evidenceFileRoots === 0)
return {
reason: "policy_disabled",
remediation: "Configure or grant an evidence_read root.",
};
if (kind === "session") return { reason: "available", remediation: null };
if (!targetOpen)
return {
reason: "target_required",
remediation: "Call open_binary with a supported local target.",
};
if (
targetKind !== undefined &&
targetKind !== "executable" &&
targetKind !== "database" &&
(kind === "official-proxy" || kind === "enhanced")
)
return {
reason: "target_unsupported",
remediation:
"Inventory or extract a native executable, then call open_binary on that executable.",
};
if (kind === "enhanced") return composedAvailability(name, descriptors);
const descriptor = descriptors.get(name);
if (descriptor === undefined)
return {
reason: "provider_missing",
remediation:
"Install or configure a provider that declares this operation.",
};
if (
!descriptor.available &&
descriptor.reason?.toLowerCase().includes("require macos") === true
)
return {
reason: "unsupported_host",
remediation: descriptor.reason,
};
return descriptor.available
? { reason: "available", remediation: null }
: {
reason: "provider_unavailable",
remediation:
descriptor.reason ?? "Choose another target or configured provider.",
};
};
const composedAvailability = (
name: string,
descriptors: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
>,
): {
readonly reason: ToolAvailabilityReason;
readonly remediation: string | null;
} => {
const requirements = ENHANCED_REQUIREMENTS[name];
if (requirements === undefined)
return {
reason: "provider_missing",
remediation: "No provider composition is declared for this operation.",
};
const missing = requirements.find((operation) => !descriptors.has(operation));
if (missing !== undefined)
return {
reason: "provider_missing",
remediation: `Configure a provider for required operation ${missing}.`,
};
const unavailable = requirements
.map((operation) => descriptors.get(operation))
.find((descriptor) => descriptor?.available === false);
if (unavailable !== undefined)
return {
reason:
unavailable.reason?.toLowerCase().includes("require macos") === true
? "unsupported_host"
: "provider_unavailable",
remediation:
unavailable.reason ?? "Choose another target or configured provider.",
};
return { reason: "available", remediation: null };
};

View File

@ -0,0 +1,119 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parse as parseToml } from "smol-toml";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients } from "./SupportedClients.js";
export interface ClientRegistrationStatus {
readonly client: string;
readonly config_path: string;
readonly command: readonly string[];
readonly state: "aligned" | "stale" | "missing" | "invalid";
readonly remediation: string | null;
}
const objectSchema = z.record(z.string(), z.unknown());
const registrationSchema = z
.object({ command: z.string().min(1), args: z.array(z.string()).default([]) })
.passthrough();
/** Inspect supported client registrations without reading their environment. */
export const readClientRegistrationStatuses = async (
home: string,
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
): Promise<readonly ClientRegistrationStatus[]> => {
const statuses: ClientRegistrationStatus[] = [];
for (const client of supportedClients(home)) {
if (client.format === "unsupported" || !(await exists(client.markerPath)))
continue;
try {
const content = await readFile(client.configPath, "utf8");
const document = objectSchema.parse(
client.format === "toml" ? parseToml(content) : JSON.parse(content),
);
const servers = objectSchema.parse(
document[client.format === "toml" ? "mcp_servers" : "mcpServers"] ?? {},
);
const raw = servers[PRODUCT_IDENTITY.mcpServerKey];
if (raw === undefined) {
statuses.push(status(client.name, client.configPath, [], "missing"));
continue;
}
const registration = registrationSchema.parse(raw);
const command = [registration.command, ...registration.args];
statuses.push(
status(
client.name,
client.configPath,
command,
registrationAligned(command, currentCommandPath)
? "aligned"
: "stale",
),
);
} catch (cause: unknown) {
statuses.push(
status(
client.name,
client.configPath,
[],
isMissing(cause) ? "missing" : "invalid",
),
);
}
}
return statuses.sort((left, right) =>
left.client.localeCompare(right.client),
);
};
const registrationAligned = (
command: readonly string[],
currentCommandPath: string,
): boolean => {
if (
command.length === 4 &&
command[0] === "npx" &&
command[1] === "-y" &&
command[2] === PRODUCT_IDENTITY.packageName &&
command[3] === "mcp"
)
return true;
return (
command.length === 2 &&
command[1] === "mcp" &&
resolve(command[0] ?? "") === currentCommandPath
);
};
const status = (
client: string,
configPath: string,
command: readonly string[],
state: ClientRegistrationStatus["state"],
): ClientRegistrationStatus => ({
client,
config_path: configPath,
command,
state,
remediation:
state === "aligned"
? null
: "Run rea setup to refresh this registration, then restart the client.",
});
const exists = async (path: string | undefined): Promise<boolean> => {
if (path === undefined) return false;
try {
await access(path);
return true;
} catch {
return false;
}
};
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";

View File

@ -1,6 +1,5 @@
import { constants } from "node:fs";
import { access } from "node:fs/promises";
import { readdir } from "node:fs/promises";
import { access, readFile, readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
@ -16,6 +15,12 @@ import {
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
import {
readClientRegistrationStatuses,
type ClientRegistrationStatus,
} from "./ClientRegistrationStatus.js";
const execFileAsync = promisify(execFile);
const DEFAULT_HOPPER =
@ -48,6 +53,9 @@ export interface DoctorHost {
linuxDemoRuntimeReady(): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
installationPaths?(): Promise<readonly string[]>;
installedSkillVersion?(): Promise<string | undefined>;
clientRegistrations?(): Promise<readonly ClientRegistrationStatus[]>;
}
/**
@ -62,6 +70,26 @@ export const runDoctor = async (
readonly healthy: boolean;
readonly hopperPath?: string;
readonly checks: readonly DoctorCheck[];
readonly identity?: {
readonly cli_package_version: string;
readonly expected_skill_version: string;
readonly sdk: typeof SDK_IDENTITY;
readonly catalog: typeof CATALOG_IDENTITY;
readonly live_server: {
readonly state: "unknown";
readonly remediation: string;
};
readonly installations: {
readonly paths: readonly string[];
readonly state: "single" | "multiple" | "unknown";
};
readonly skill: {
readonly installed_version: string | null;
readonly state: "aligned" | "stale" | "unknown";
readonly remediation: string | null;
};
readonly registrations: readonly ClientRegistrationStatus[];
};
}> => {
const checks: DoctorCheck[] = [];
checks.push(
@ -148,10 +176,59 @@ export const runDoctor = async (
classification: "config_drift",
}),
);
const installationPaths = (await host.installationPaths?.()) ?? [];
const installedSkillVersion = await host.installedSkillVersion?.();
const registrations = (await host.clientRegistrations?.()) ?? [];
for (const registration of registrations)
if (registration.state !== "aligned")
checks.push({
name: `registration:${registration.client}`,
ok: false,
classification: "config_drift",
detail: registration.config_path,
remediation:
registration.remediation ??
"Run rea setup, then restart the affected client.",
});
return {
healthy: checks.every(({ ok }) => ok),
...(hopperPath === undefined ? {} : { hopperPath }),
checks,
identity: {
cli_package_version: PRODUCT_IDENTITY.packageVersion,
expected_skill_version: PRODUCT_IDENTITY.skillVersion,
sdk: SDK_IDENTITY,
catalog: CATALOG_IDENTITY,
live_server: {
state: "unknown",
remediation:
"Compare rea://server/identity or binary_session from the active client; an on-disk registration cannot prove the running server version.",
},
installations: {
paths: installationPaths,
state:
installationPaths.length === 0
? "unknown"
: installationPaths.length === 1
? "single"
: "multiple",
},
skill: {
installed_version: installedSkillVersion ?? null,
state:
installedSkillVersion === undefined
? "unknown"
: installedSkillVersion === PRODUCT_IDENTITY.skillVersion
? "aligned"
: "stale",
remediation:
installedSkillVersion === undefined ||
installedSkillVersion === PRODUCT_IDENTITY.skillVersion
? null
: "Run rea setup to update the installed REA skill.",
},
registrations,
},
};
};
@ -241,10 +318,44 @@ export const systemDoctorHost = (): DoctorHost => ({
}
return paths;
},
async installationPaths() {
try {
const command = process.platform === "win32" ? "where" : "which";
const arguments_ = process.platform === "win32" ? ["rea"] : ["-a", "rea"];
return uniqueLines((await execFileAsync(command, arguments_)).stdout);
} catch {
return [];
}
},
async installedSkillVersion() {
try {
const content = await readFile(
join(
homedir(),
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
),
"utf8",
);
return /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1];
} catch {
return undefined;
}
},
clientRegistrations: () => readClientRegistrationStatuses(homedir()),
});
const parseMajor = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const uniqueLines = (value: string): string[] => [
...new Set(
value
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0),
),
];
const check = (
name: string,
ok: boolean,

View File

@ -8,7 +8,7 @@ import {
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { dirname, resolve } from "node:path";
import { z } from "zod";
import writeFileAtomic from "write-file-atomic";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
@ -23,6 +23,8 @@ import {
} from "./LinuxHopper.js";
import { installMacHopper } from "./MacHopper.js";
import { configureDetectedClients } from "./SetupClients.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
export type { SetupClient } from "./SupportedClients.js";
import { installCanonicalSkill } from "./SetupSkill.js";
import { setupPlan } from "./SetupPlan.js";
import {
@ -39,13 +41,6 @@ const registrationCommand = (): readonly string[] =>
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
/** A detected agent configuration owned by setup. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
readonly markerPath?: string;
readonly format?: "json" | "toml" | "unsupported";
}
/** Result of one backup/write/readback transaction. */
export type ClientConfigurationResult =
| {
@ -282,50 +277,6 @@ export const detectClients = async (
return detected;
};
/** Describe every client location that setup or uninstall may own. */
export const supportedClients = (home: string): readonly SetupClient[] => [
{
name: "claude_code",
configPath: join(home, ".claude.json"),
markerPath: join(home, ".claude"),
},
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
markerPath: join(home, "Library/Application Support/Claude"),
},
{
name: "codex",
configPath: join(home, ".codex/config.toml"),
markerPath: join(home, ".codex"),
format: "toml" as const,
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
markerPath: join(home, ".cursor"),
},
{
name: "gemini_cli",
configPath: join(home, ".gemini/settings.json"),
markerPath: join(home, ".gemini"),
},
{
name: "windsurf",
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
markerPath: join(home, ".codeium/windsurf"),
},
{
name: "devin",
configPath: join(home, ".devin"),
markerPath: join(home, ".devin"),
format: "unsupported" as const,
},
];
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,

View File

@ -0,0 +1,53 @@
import { join } from "node:path";
/** One supported client configuration location. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
readonly markerPath?: string;
readonly format?: "json" | "toml" | "unsupported";
}
/** Describe every client location that setup, doctor, or uninstall may inspect. */
export const supportedClients = (home: string): readonly SetupClient[] => [
{
name: "claude_code",
configPath: join(home, ".claude.json"),
markerPath: join(home, ".claude"),
},
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
markerPath: join(home, "Library/Application Support/Claude"),
},
{
name: "codex",
configPath: join(home, ".codex/config.toml"),
markerPath: join(home, ".codex"),
format: "toml",
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
markerPath: join(home, ".cursor"),
},
{
name: "gemini_cli",
configPath: join(home, ".gemini/settings.json"),
markerPath: join(home, ".gemini"),
},
{
name: "windsurf",
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
markerPath: join(home, ".codeium/windsurf"),
},
{
name: "devin",
configPath: join(home, ".devin"),
markerPath: join(home, ".devin"),
format: "unsupported",
},
];

View File

@ -7,7 +7,7 @@ import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients, type SetupClient } from "./Setup.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
interface ManagedPathStats {
isSymbolicLink(): boolean;

View File

@ -52,6 +52,8 @@ export type UpgradeResult =
readonly versionCheck: "available" | "unavailable";
readonly installMethod: "npm";
readonly command: typeof UPGRADE_COMMAND;
readonly clientRestartRequired: true;
readonly remediation: string;
}
| {
readonly status: "failed";
@ -102,6 +104,9 @@ export const runUpgrade = async (
versionCheck: latestVersion === undefined ? "unavailable" : "available",
installMethod: "npm",
command: UPGRADE_COMMAND,
clientRestartRequired: true,
remediation:
"Rerun rea setup to refresh registrations and the skill, then restart clients that may retain an older MCP server.",
};
};

129
src/catalogIdentity.ts Normal file
View File

@ -0,0 +1,129 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js";
import { TOOL_CONTRACTS } from "./contracts/toolContracts.js";
/** Explicit CLI command inventory, kept separate from MCP tool counts. */
export const CLI_COMMAND_NAMES = [
"analyze",
"inspect",
"decompile",
"setup",
"doctor",
"uninstall",
"upgrade",
"xrefs",
"trace",
"capabilities",
"providers",
"function",
"search",
"import-reference-source",
"inventory-artifact",
"extract-artifact",
"inspect-macho",
"inspect-signature",
"list-architectures",
"inspect-plist",
"demangle-swift",
"evidence-import",
"evidence-export",
"compare",
"investigate-versions",
"capture-process",
"compare-process-captures",
"policy",
] as const;
const toolCatalog = TOOL_CONTRACTS.map((contract) => ({
name: contract.name,
surface: contract.kind,
description: contract.description,
annotations: contract.annotations,
input_schema: z.toJSONSchema(contract.inputSchema, {
unrepresentable: "any",
}),
output_schema: z.toJSONSchema(contract.outputSchema, {
unrepresentable: "any",
}),
})).sort((left, right) => left.name.localeCompare(right.name));
const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({
name: contract.name,
title: contract.title,
description: contract.description,
arguments: contract.arguments,
steps: contract.steps,
})).sort((left, right) => left.name.localeCompare(right.name));
const resourceCatalog = [
{ name: "server-identity", uri: "rea://server/identity" },
{ name: "active-residual-unknowns", uri: "rea://unknowns/active" },
] as const;
const resourceTemplateCatalog = [
{ name: "session-evidence", uri_template: "rea://evidence/{evidenceId}" },
{
name: "evidence-section",
uri_template: "rea://evidence/{evidenceId}/section/{section}",
},
{ name: "residual-unknown", uri_template: "rea://unknown/{unknownId}" },
{
name: "analysis-snapshot",
uri_template: "rea://snapshot/{snapshotDigest}",
},
{
name: "artifact-page",
uri_template: "rea://artifact/{manifestId}/{collection}",
},
{
name: "function-dossier",
uri_template: "rea://function/{targetSha256}/{address}",
},
{
name: "investigation-workspace-revision",
uri_template: "rea://workspace/{workspaceId}/revision/{revision}",
},
] as const;
const digest = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Catalog is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
/** Stable, schema-sensitive identity for every public REA surface. */
export const CATALOG_IDENTITY = {
counts: {
cli_commands: CLI_COMMAND_NAMES.length,
mcp_tools: toolCatalog.length,
mcp_prompts: promptCatalog.length,
mcp_resources: resourceCatalog.length,
mcp_resource_templates: resourceTemplateCatalog.length,
},
digests: {
tools_sha256: digest(toolCatalog),
prompts_sha256: digest(promptCatalog),
resources_sha256: digest({ resourceCatalog, resourceTemplateCatalog }),
combined_sha256: digest({
cli: CLI_COMMAND_NAMES,
tools: toolCatalog,
prompts: promptCatalog,
resources: resourceCatalog,
resource_templates: resourceTemplateCatalog,
}),
},
tools: toolCatalog.map(({ name, surface, annotations }) => ({
name,
surface,
annotations: {
read_only: annotations.readOnlyHint ?? false,
destructive: annotations.destructiveHint ?? false,
idempotent: annotations.idempotentHint ?? false,
open_world: annotations.openWorldHint ?? true,
},
})),
} as const;

View File

@ -0,0 +1,9 @@
/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "1.3.0",
serverSdkVersion: "2.0.0-beta.4",
clientSdkVersion: "2.0.0-beta.4",
coreSdkVersion: "2.0.0-beta.4",
skillVersion: "12",
} as const;

View File

@ -5,12 +5,20 @@
*/
export const PRODUCT_IDENTITY = {
displayName: "REA",
packageName: "rea-agents",
packageVersion: "1.1.0",
packageName: PACKAGE_METADATA.name,
packageVersion: PACKAGE_METADATA.version,
cliBinary: "rea",
mcpCommand: "npx -y rea-agents mcp",
mcpServerKey: "rea",
skillName: "rea-analysis",
skillVersion: "10",
skillVersion: PACKAGE_METADATA.skillVersion,
configFileName: "rea.json",
} as const;
/** Exact SDK package identities, distinct from negotiated protocol version. */
export const SDK_IDENTITY = {
server: PACKAGE_METADATA.serverSdkVersion,
client_test: PACKAGE_METADATA.clientSdkVersion,
core: PACKAGE_METADATA.coreSdkVersion,
} as const;
import { PACKAGE_METADATA } from "./generatedPackageMetadata.js";

100
src/serverIdentity.ts Normal file
View File

@ -0,0 +1,100 @@
import { dirname, basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { CATALOG_IDENTITY } from "./catalogIdentity.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "./identity.js";
/** Optional observations supplied by a client comparing its expected server. */
export interface ExpectedServerIdentity {
readonly package_version?: string;
readonly catalog_digest?: string;
readonly server_path?: string;
}
export interface ConnectedClientIdentity {
readonly name: string;
readonly version: string;
}
const packageRoot = (): string => {
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
return basename(moduleDirectory) === "src" &&
basename(dirname(moduleDirectory)) === "dist"
? resolve(moduleDirectory, "../..")
: resolve(moduleDirectory, "..");
};
/** Build one runtime identity without claiming alignment from absent observations. */
export const createServerIdentity = (input: {
readonly startedAt: string;
readonly expected?: ExpectedServerIdentity;
readonly client?: ConnectedClientIdentity;
readonly protocolVersion?: string | undefined;
}) => {
const serverPath = resolve(process.argv[1] ?? "unknown");
const reasons: string[] = [];
if (
input.expected?.package_version !== undefined &&
input.expected.package_version !== PRODUCT_IDENTITY.packageVersion
)
reasons.push("package_version_mismatch");
if (
input.expected?.catalog_digest !== undefined &&
input.expected.catalog_digest !== CATALOG_IDENTITY.digests.combined_sha256
)
reasons.push("catalog_digest_mismatch");
if (
input.expected?.server_path !== 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);
return {
package: {
name: PRODUCT_IDENTITY.packageName,
version: PRODUCT_IDENTITY.packageVersion,
root_path: packageRoot(),
build_commit: process.env.REA_BUILD_COMMIT ?? null,
},
server: {
name: PRODUCT_IDENTITY.mcpServerKey,
version: PRODUCT_IDENTITY.packageVersion,
started_at: input.startedAt,
command_path: serverPath,
},
sdk: SDK_IDENTITY,
negotiated_protocol_version: input.protocolVersion ?? null,
client:
input.client === undefined
? null
: { name: input.client.name, version: input.client.version },
skill: {
name: PRODUCT_IDENTITY.skillName,
expected_version: PRODUCT_IDENTITY.skillVersion,
},
catalog: CATALOG_IDENTITY,
protocol_features: {
progress: true,
cancellation: true,
evidence_resources: true,
elicitation: false,
},
alignment: {
state:
reasons.length > 0
? "mcp_server_restart_required"
: observedExpectation
? "aligned"
: "unknown",
reasons,
remediation:
reasons.length > 0
? "Restart the registered MCP server/client, then compare identity again."
: observedExpectation
? null
: "Supply expected package, catalog, or registration identity to compare the live server.",
},
} as const;
};

View File

@ -0,0 +1,47 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readClientRegistrationStatuses } from "../src/application/ClientRegistrationStatus.js";
describe("client registration status", () => {
it("distinguishes aligned, stale, missing, and invalid registrations", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-registrations-"));
await Promise.all([
mkdir(join(home, ".codex")),
mkdir(join(home, ".cursor")),
mkdir(join(home, ".gemini")),
mkdir(join(home, ".claude")),
]);
await writeFile(
join(home, ".codex/config.toml"),
'[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents", "mcp"]\n',
);
await writeFile(
join(home, ".cursor/mcp.json"),
JSON.stringify({
mcpServers: {
rea: { command: "/old/rea", args: ["mcp"], env: { SECRET: "x" } },
},
}),
);
await writeFile(join(home, ".gemini/settings.json"), "not-json");
const statuses = await readClientRegistrationStatuses(home, "/current/rea");
expect(statuses).toEqual(
expect.arrayContaining([
expect.objectContaining({ client: "codex", state: "aligned" }),
expect.objectContaining({
client: "cursor",
state: "stale",
command: ["/old/rea", "mcp"],
}),
expect.objectContaining({ client: "gemini_cli", state: "invalid" }),
expect.objectContaining({ client: "claude_code", state: "missing" }),
]),
);
expect(JSON.stringify(statuses)).not.toContain("SECRET");
});
});

View File

@ -59,6 +59,57 @@ describe("doctor", () => {
expect(result.hopperPath).toBe("/manual/Hopper");
expect(result.healthy).toBe(true);
});
it("distinguishes stale skills, path shadowing, and unobservable live state", async () => {
const result = await runDoctor(
undefined,
host({
installationPaths: () =>
Promise.resolve(["/usr/local/bin/rea", "/home/user/bin/rea"]),
installedSkillVersion: () => Promise.resolve("10"),
}),
);
expect(result.identity).toMatchObject({
live_server: { state: "unknown" },
installations: { state: "multiple" },
skill: {
installed_version: "10",
state: "stale",
remediation: "Run rea setup to update the installed REA skill.",
},
});
});
it("reports stale client registration paths without claiming live state", async () => {
const result = await runDoctor(
undefined,
host({
clientRegistrations: () =>
Promise.resolve([
{
client: "codex",
config_path: "/home/user/.codex/config.toml",
command: ["/old/rea", "mcp"],
state: "stale",
remediation:
"Run rea setup to refresh this registration, then restart the client.",
},
]),
}),
);
expect(result.healthy).toBe(false);
expect(result.identity).toMatchObject({
live_server: { state: "unknown" },
registrations: [{ client: "codex", state: "stale" }],
});
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "registration:codex",
classification: "config_drift",
}),
);
});
it("accepts an officially supported Linux distribution", async () => {
const path = "/home/user/.local/share/rea/hopper/bin/Hopper";
const result = await runDoctor(

View File

@ -0,0 +1,198 @@
import { readFile } from "node:fs/promises";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import { BinarySession } from "../src/application/BinarySession.js";
import { CATALOG_IDENTITY, CLI_COMMAND_NAMES } from "../src/catalogIdentity.js";
import { PACKAGE_METADATA } from "../src/generatedPackageMetadata.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../src/identity.js";
import { createServer } from "../src/server/createServer.js";
import { createServerIdentity } from "../src/serverIdentity.js";
import { observed } from "./fixtures/analysisExecution.js";
import { buildCapabilityInventory } from "../src/application/CapabilityInventory.js";
describe("server and catalog identity", () => {
it("derives package and SDK versions from canonical package metadata", async () => {
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
expect(PACKAGE_METADATA).toMatchObject({
name: packageJson.name,
version: packageJson.version,
serverSdkVersion: "2.0.0-beta.4",
clientSdkVersion: "2.0.0-beta.4",
coreSdkVersion: "2.0.0-beta.4",
});
expect(PRODUCT_IDENTITY.packageVersion).toBe(packageJson.version);
expect(SDK_IDENTITY.server).toBe("2.0.0-beta.4");
expect(CLI_COMMAND_NAMES).toHaveLength(28);
expect(new Set(CLI_COMMAND_NAMES).size).toBe(28);
expect(CATALOG_IDENTITY.counts).toEqual({
cli_commands: 28,
mcp_tools: 68,
mcp_prompts: 6,
mcp_resources: 2,
mcp_resource_templates: 7,
});
expect(CATALOG_IDENTITY.digests.combined_sha256).toMatch(/^[a-f0-9]{64}$/u);
});
it("reports unknown without a live comparison and distinguishes aligned from stale", () => {
const unknown = createServerIdentity({
startedAt: "2026-07-13T00:00:00.000Z",
});
expect(unknown.alignment.state).toBe("unknown");
const aligned = createServerIdentity({
startedAt: "2026-07-13T00:00:00.000Z",
expected: {
package_version: PRODUCT_IDENTITY.packageVersion,
catalog_digest: CATALOG_IDENTITY.digests.combined_sha256,
server_path: process.argv[1] ?? "unknown",
},
});
expect(aligned.alignment).toMatchObject({ state: "aligned", reasons: [] });
const stale = createServerIdentity({
startedAt: "2026-07-13T00:00:00.000Z",
expected: {
package_version: "1.2.0",
catalog_digest: "0".repeat(64),
},
});
expect(stale.alignment).toMatchObject({
state: "mcp_server_restart_required",
reasons: ["package_version_mismatch", "catalog_digest_mismatch"],
});
});
it("reports composed, host, and target-specific availability truthfully", () => {
const policy = { processCaptureEnabled: true, evidenceFileRoots: 1 };
const composed = buildCapabilityInventory(
{
open: true,
kind: "executable",
format: "mach-o",
capabilities: [
"list_segments",
"list_documents",
"list_procedures",
"list_strings",
].map((operation) => ({ operation, available: true, reason: null })),
},
policy,
);
expect(composed).toContainEqual(
expect.objectContaining({ name: "binary_overview", available: true }),
);
const artifactTarget = buildCapabilityInventory(
{
open: true,
kind: "artifact",
format: "javascript",
capabilities: [
{ operation: "current_address", available: true, reason: null },
],
},
policy,
);
expect(artifactTarget).toContainEqual(
expect.objectContaining({
name: "current_address",
available: false,
reason: "target_unsupported",
}),
);
const unsupportedHost = buildCapabilityInventory(
{
open: true,
kind: "executable",
format: "elf",
capabilities: [
{
operation: "inspect_macho",
available: false,
reason: "Native macOS utilities require macOS.",
},
],
},
policy,
);
expect(unsupportedHost).toContainEqual(
expect.objectContaining({
name: "inspect_macho",
available: false,
reason: "unsupported_host",
}),
);
});
it("exposes live identity, labeled inventories, availability, and list changes", async () => {
const session = new BinarySession(() => ({
health: () => Promise.resolve(),
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(),
}));
const server = createServer(session, session);
const client = new Client({ name: "identity-test", version: "9" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
let toolListChanges = 0;
client.setNotificationHandler("notifications/tools/list_changed", () => {
toolListChanges += 1;
});
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const resource = await client.readResource({
uri: "rea://server/identity",
});
const content = resource.contents[0];
expect(content).toBeDefined();
if (content === undefined || !("text" in content)) return;
const live = JSON.parse(content.text);
expect(live).toMatchObject({
package: { version: PRODUCT_IDENTITY.packageVersion },
server: { version: PRODUCT_IDENTITY.packageVersion },
sdk: { server: "2.0.0-beta.4", client_test: "2.0.0-beta.4" },
client: { name: "identity-test", version: "9" },
alignment: { state: "unknown" },
});
const status = await client.callTool({
name: "binary_session",
arguments: { expected_package_version: "1.2.0" },
});
expect(status.structuredContent).toMatchObject({
result: {
server_identity: {
catalog: { counts: { mcp_tools: 68, cli_commands: 28 } },
alignment: { state: "mcp_server_restart_required" },
},
tool_availability: expect.arrayContaining([
expect.objectContaining({
name: "current_address",
available: false,
reason: "target_required",
}),
expect.objectContaining({
name: "capture_process_scenario",
available: false,
reason: "policy_disabled",
}),
]),
client_features: {
elicitation_form: false,
elicitation_url: false,
roots: false,
sampling: false,
},
},
});
await client.callTool({ name: "close_binary", arguments: {} });
await expect.poll(() => toolListChanges).toBeGreaterThan(0);
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
});
});

View File

@ -54,6 +54,9 @@ describe("CLI upgrade", () => {
versionCheck: "available",
installMethod: "npm",
command: "npm install --global rea-agents@latest",
clientRestartRequired: true,
remediation:
"Rerun rea setup to refresh registrations and the skill, then restart clients that may retain an older MCP server.",
});
expect(host.installs).toBe(1);
});