feat(identity): derive MCP surface metadata
This commit is contained in:
parent
964620a0a1
commit
2722722425
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue