perf(mcp): add opt-in hot/cold tool discovery (#9046)

Add per-connection discovery that loads canonical named tools on demand while preserving the default full catalog. Bound discovery replies before changing visibility, preserve family exclusions and Evidence workflows, and reset visibility on HTTP reconnect.

Verified with focused protocol tests and real Codex CLI artifact investigations; token costs remain task-dependent, so the profile is opt-in.
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-11-05 20:27:31 +00:00
1 parent ad518cff65
commit 0beedcd712
22 files changed
+947 -33

No files matched your search

+27
View File
@@ -102,6 +102,33 @@ does not repair a client's naming behavior or infer permission drift from an
unfamiliar prefix. The unavailable fork reported in
[#5512](https://github.com/morluto/rea/issues/5512) has not been reverified.
## Hot/cold catalogs
For a client that supports MCP catalog refresh, set
`REA_MCP_TOOL_CATALOG_PROFILE=hot-cold` in REA's server environment and restart
the connection. REA then advertises a small initial catalog and loads other
named tools through `discover_tools`. Setup preserves this explicit setting.
See the [discovery contract](mcp-contracts.md#hotcold-catalog-discovery) for
pagination, identity and family-scope semantics.
Codex CLI 0.165.0 requires its experimental `mcp_tool_list_changed` feature for
this workflow. Merge these entries into the existing `~/.codex/config.toml`
without replacing other feature or REA settings:
```toml
[features]
mcp_tool_list_changed = true
[mcp_servers.rea.env]
REA_MCP_TOOL_CATALOG_PROFILE = "hot-cold"
```
The server setting alone does not enable client refresh. If a successful
discovery call is followed by a missing tool, check the client's refresh feature
and tool allow/deny lists. Clients without refresh should use the default
`REA_MCP_TOOL_CATALOG_PROFILE=full`; reconnect after changing it. Other clients'
hot/cold behavior has not been verified.
## Fiddler Everywhere alongside REA
Connect Fiddler Everywhere's MCP server separately for HTTP traffic debugging,
+43 -2
View File
@@ -899,7 +899,7 @@ registrations, reports their command vectors as aligned, stale, missing, or
invalid, and keeps live-server state `unknown` unless the active connection
supplies identity.
`tools/list` returns the complete canonical tool inventory, including tools that
By default, `tools/list` returns the complete canonical tool inventory, including tools that
are currently unavailable. Opening or closing a target or observing a provider
health transition leaves that catalog unchanged and does not emit
`notifications/tools/list_changed`.
@@ -911,7 +911,7 @@ share status facts. `tool_availability.client_requirements` is the baseline for
each group; a group's optional `client_requirements` replaces that baseline for
the group's tools. Expand each group's `names` into one tool entry, carrying its
availability, reason, remediation, default-mode status, modes, and client
requirements. Compare the union of group names with `tools/list` when checking
requirements. With the default full catalog, compare the union of group names with `tools/list` when checking
that a client received the full catalog. An operation absent from all groups is
missing from the complete inventory; it is not an implicit unavailable entry.
@@ -1873,6 +1873,47 @@ check full-profile local-path rules. Compact MCP workflow tests verify canonical
rejection of invalid inputs before provider work, independent of how much detail
the selected advertisement retains.
### Hot/cold catalog discovery
`REA_MCP_TOOL_CATALOG_PROFILE=hot-cold` opts into per-connection loading for
clients that refresh `tools/list` after `notifications/tools/list_changed`.
The default, `full`, retains the complete upfront advertisement. Restart the
server to change profiles. See [client configuration](client-configuration.md#hotcold-catalogs).
Hot/cold initially advertises `binary_session`, `open_binary`, `close_binary`,
`get_evidence_bundle`, `inspect_analysis_view`, and the adapter's `discover_tools`.
An explicit tool-family scope can remove any of those domain tools.
`discover_tools` searches canonical names and descriptions using case-insensitive
words, or accepts exact `names`. It loads the matching page and returns names
and abbreviated descriptions with their configured input schemas, so the next
call can use the returned arguments contract. Output schemas remain in
`tools/list`. Search defaults to three results, allows up to
20 per page, and returns `total_matches` and `next_offset`. Exact names preserve
request order and reject unknown or excluded names before loading anything.
Use fewer keywords when no result matches; search requires every word to match.
Both reply representations must fit the configured response budget before any
tools load. An oversized page returns a resource constraint; reduce `limit` or
request fewer exact names and retry. Input schemas are retained in full within
the selected input profile.
Loaded definitions retain their original names, complete configured schemas,
annotations, handlers and client approval rules. Loading does not execute an
analysis, open a target, or initialize the deferred analysis runtime. A batch
emits one list-change notification; repeating it emits none. Loaded tools remain
available until the connection ends. There is no generic execution gateway.
If a loaded tool does not appear in the client, enable client catalog refresh
or reconnect using `REA_MCP_TOOL_CATALOG_PROFILE=full`.
The catalog identity covers the complete in-scope inventory, the discovery
contract and the hot/cold policy; its count includes `discover_tools`. It differs
from full advertisement and remains stable as tools load. `rea mcp doctor`
checks the profile's initial advertisement and identity. The domain inventory in
`binary_session.tool_availability` continues to describe provider and target
availability, independently of whether a definition is loaded. Load a listed
cold tool before calling it. Provider health and target changes do not load,
unload or remove definitions. The input/output schema profiles and explicit
family exclusions continue to apply.
### Tool family advertising scope
Agents pay context for every advertised tool schema, and clients that send
+5 -2
View File
@@ -8,8 +8,11 @@ Extend a tool when intent and result contract match; add one for a distinct
outcome or materially different authority. Public names and semantics remain
provider-neutral, with exact coverage reported per provider.
Keep the catalog complete; report capability/session availability instead of
truncating schemas. Serialized bytes alone do not establish agent usability.
Keep the discoverable inventory complete within the caller's selected scope;
report capability/session availability independently of catalog visibility.
An opt-in dynamic catalog must retain canonical named contracts and provide
discovery and recovery for clients without refresh. Serialized bytes alone do
not establish agent usability.
## Choose the tool shape
+20 -12
View File
@@ -7,18 +7,11 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
import { INLINE_EVIDENCE_MCP_ENVIRONMENT } from "./mcp-verification-environment.mjs";
// REA registers process-static tool contracts. Bind their parsed definitions to
// the SDK's negotiated connection identity, including when a Client reconnects.
// Bind parsed definitions to this connection. Hot/cold catalogs may add tools;
// refresh a missing name once without throwing away already validated bindings.
const toolBindings = new WeakMap();
const advertisedTool = async (client, name) => {
const connection = client.getServerVersion();
assert.ok(connection, "MCP must be initialized before binding tool schemas");
let binding = toolBindings.get(client);
if (binding?.connection !== connection) {
binding = {
connection,
definitions: client.listTools().then(({ tools }) => {
const readDefinitions = (client) =>
client.listTools().then(({ tools }) => {
const definitions = new Map();
for (const tool of tools) {
assert.ok(
@@ -28,13 +21,28 @@ const advertisedTool = async (client, name) => {
definitions.set(tool.name, tool);
}
return definitions;
}),
});
const advertisedTool = async (client, name) => {
const connection = client.getServerVersion();
assert.ok(connection, "MCP must be initialized before binding tool schemas");
let binding = toolBindings.get(client);
if (binding?.connection !== connection) {
binding = {
connection,
definitions: readDefinitions(client),
};
toolBindings.set(client, binding);
}
let definitions;
const snapshot = binding.definitions;
try {
definitions = await snapshot;
if (!definitions.has(name)) {
if (binding.definitions === snapshot)
binding.definitions = readDefinitions(client);
definitions = await binding.definitions;
}
assert.equal(
client.getServerVersion(),
connection,
+14 -9
View File
@@ -8,6 +8,7 @@ import { providerCapabilitySets } from "./binary/BinarySessionStatus.js";
import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { catalogIdentityForToolFamilies } from "../catalogIdentity.js";
import { progressiveCatalogIdentity } from "../catalogDiscovery.js";
import { compactPresentationForMcpInputSchemaProfile } from "../config/mcpInputSchemaProfile.js";
import {
directAnalysisCleanupFailure,
@@ -31,6 +32,15 @@ export const runSessionStatus = async (
session,
async () => {
const status = jsonObjectSchema.parse(await session.status());
const catalog = catalogIdentityForToolFamilies(
compactPresentationForMcpInputSchemaProfile(
config.value.mcpInputSchemaProfile,
),
config.value.mcpOutputSchemaProfile ?? "full",
config.value.mcpToolFamilies === undefined
? undefined
: new Set(config.value.mcpToolFamilies),
);
return {
...status,
capabilities: [
@@ -42,15 +52,10 @@ export const runSessionStatus = async (
server_identity: createServerIdentity({
startedAt: new Date().toISOString(),
commandPath: CURRENT_ENTRYPOINT_PATH,
catalog: catalogIdentityForToolFamilies(
compactPresentationForMcpInputSchemaProfile(
config.value.mcpInputSchemaProfile,
),
config.value.mcpOutputSchemaProfile ?? "full",
config.value.mcpToolFamilies === undefined
? undefined
: new Set(config.value.mcpToolFamilies),
),
catalog:
config.value.mcpToolCatalogProfile === "hot-cold"
? progressiveCatalogIdentity(catalog)
: catalog,
}),
};
},
@@ -72,6 +72,7 @@ const SELECTED_REGISTRATION_SETTINGS = [
"REA_MCP_INPUT_SCHEMA_PROFILE",
"REA_MCP_TOOL_FAMILIES",
"REA_MCP_OUTPUT_SCHEMA_PROFILE",
"REA_MCP_TOOL_CATALOG_PROFILE",
"REA_MCP_MAX_RESPONSE_BYTES",
"REA_MCP_MAX_INPUT_ELEMENTS",
"REA_NATIVE_MATCHING_REPRESENTATION_BYTES",
+110
View File
@@ -0,0 +1,110 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import type { createCatalogIdentity } from "./catalogIdentity.js";
export const HOT_TOOL_NAMES: ReadonlySet<string> = new Set([
"binary_session",
"open_binary",
"close_binary",
"get_evidence_bundle",
"inspect_analysis_view",
]);
// One page activates at most 20 schemas: callers can page a broad search instead
// of inadvertently expanding the entire catalog into their client's context.
const pageSize = z.int().min(1).max(20).default(3);
const inputSchema = z
.strictObject({
query: z
.string()
.trim()
.optional()
.describe(
"Case-insensitive words matched against tool names and descriptions; all words must match. Omit to browse.",
),
names: z
.array(z.string().min(1))
.min(1)
.max(20)
.optional()
.describe("Exact tool names to load instead of searching."),
offset: z.int().min(0).default(0),
limit: pageSize.describe(
"Search page size; does not apply to exact names.",
),
})
.refine(
(input) =>
input.names === undefined ||
(input.query === undefined && input.offset === 0),
{
message: "Use names alone, or query with offset/limit.",
},
);
const outputSchema = z.strictObject({
profile: z.literal("hot-cold"),
inventory_count: z.int().nonnegative(),
loaded_count: z.int().nonnegative(),
total_matches: z.int().nonnegative(),
next_offset: z.int().nonnegative().nullable(),
tools: z.array(
z.strictObject({
name: z.string(),
description: z
.string()
.describe(
"Abbreviated canonical description; read tools/list for the complete contract.",
),
input_schema: z
.record(z.string(), z.unknown())
.describe(
"The configured advertised input schema for calling this named tool.",
),
}),
),
next_step: z.string(),
});
export const DISCOVER_TOOLS = {
name: "discover_tools",
description:
"Find and load REA tools on demand. Search by task keywords (for example javascript, sqlite, strings, imports), or supply exact names. Returned tools become callable with their original names and full schemas after your client refreshes tools/list. Loaded tools remain available for this connection. This only changes catalog visibility; it does not run analysis or grant execution authority. If tools do not appear, enable MCP catalog refresh in your client or use REA_MCP_TOOL_CATALOG_PROFILE=full.",
inputSchema,
outputSchema,
annotations: {
readOnlyHint: true,
idempotentHint: true,
openWorldHint: false,
},
} as const;
export type CatalogIdentity = ReturnType<typeof createCatalogIdentity>;
const digest = (value: unknown): string =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");
/** Identity covers the discoverable inventory and policy, not mutable visibility. */
export const progressiveCatalogIdentity = (
base: CatalogIdentity,
): CatalogIdentity => {
const policy = {
profile: "hot-cold",
hot: [...HOT_TOOL_NAMES],
tool: {
...DISCOVER_TOOLS,
inputSchema: z.toJSONSchema(inputSchema, { io: "input" }),
outputSchema: z.toJSONSchema(outputSchema),
},
};
const tools_sha256 = digest({ base: base.digests.tools_sha256, policy });
return {
counts: { ...base.counts, mcp_tools: base.counts.mcp_tools + 1 },
digests: {
tools_sha256,
prompts_sha256: base.digests.prompts_sha256,
combined_sha256: digest({
base: base.digests.combined_sha256,
tools_sha256,
}),
},
};
};
+2
View File
@@ -26,6 +26,7 @@ import {
} from "../domain/analysisResourceLimits.js";
import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js";
import { mcpOutputSchemaProfileSchema } from "./mcpOutputSchemaProfile.js";
import { mcpToolCatalogProfileSchema } from "./mcpToolCatalogProfile.js";
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
import { asarMaxHeaderBytesSchema } from "./asarHeaderAdmission.js";
import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js";
@@ -62,6 +63,7 @@ const environmentSchema = z.object({
REA_MCP_INPUT_SCHEMA_PROFILE: mcpInputSchemaProfileSchema.optional(),
REA_MCP_TOOL_FAMILIES: mcpToolFamiliesSchema.optional(),
REA_MCP_OUTPUT_SCHEMA_PROFILE: mcpOutputSchemaProfileSchema.optional(),
REA_MCP_TOOL_CATALOG_PROFILE: mcpToolCatalogProfileSchema.optional(),
REA_IDA_MCP_CONFIG: z
.string()
.min(1)
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
/** Full advertisement remains the default for clients without catalog refresh. */
export const mcpToolCatalogProfileSchema = z.enum(["full", "hot-cold"]);
export type McpToolCatalogProfile = z.infer<typeof mcpToolCatalogProfileSchema>;
export const parseMcpToolCatalogProfile = (
value: string | undefined,
): Result<McpToolCatalogProfile | undefined, ConfigurationError> => {
if (value === undefined) return ok(undefined);
const parsed = mcpToolCatalogProfileSchema.safeParse(value);
const constraint =
'REA_MCP_TOOL_CATALOG_PROFILE must be "full" (default) or "hot-cold". Hot/cold requires a client that refreshes tools after notifications/tools/list_changed.';
return parsed.success
? ok(parsed.data)
: err(
new ConfigurationError(constraint, {
settings: [{ setting: "REA_MCP_TOOL_CATALOG_PROFILE", constraint }],
}),
);
};
+3
View File
@@ -119,6 +119,9 @@ export const parseConfig = (
...(env.REA_MCP_OUTPUT_SCHEMA_PROFILE === undefined
? {}
: { mcpOutputSchemaProfile: env.REA_MCP_OUTPUT_SCHEMA_PROFILE }),
...(env.REA_MCP_TOOL_CATALOG_PROFILE === undefined
? {}
: { mcpToolCatalogProfile: env.REA_MCP_TOOL_CATALOG_PROFILE }),
analysisProvider: env.REA_ANALYSIS_PROVIDER,
...(env.REA_IDA_MCP_CONFIG === undefined
? {}
+2
View File
@@ -7,6 +7,7 @@ import type { AnalysisProviderSelector } from "../domain/providerSelection.js";
import type { GhidraLanguageOverride } from "./ghidraLanguageOverride.js";
import type { McpInputSchemaProfile } from "./mcpInputSchemaProfile.js";
import type { McpOutputSchemaProfile } from "./mcpOutputSchemaProfile.js";
import type { McpToolCatalogProfile } from "./mcpToolCatalogProfile.js";
import type { BridgeMaxRequestBytes } from "./bridgeMaxRequestBytes.js";
import type { RegexMatchingDeadline } from "./regexMatchingDeadline.js";
import type { RegexWorkerHeapRequest } from "./regexWorkerHeapRequest.js";
@@ -18,6 +19,7 @@ export interface AppConfig {
readonly mcpMaxInputElements?: number;
readonly mcpInputSchemaProfile?: McpInputSchemaProfile;
readonly mcpOutputSchemaProfile?: McpOutputSchemaProfile;
readonly mcpToolCatalogProfile?: McpToolCatalogProfile;
readonly analysisProvider: AnalysisProviderSelector;
readonly idaMcpConfigPath?: string;
readonly ghidraInstallDir: string | undefined;
+2
View File
@@ -217,6 +217,8 @@ export const runDeferred = async (
}),
config.mcpMaxInputElements,
);
if (config.mcpToolCatalogProfile === "hot-cold")
server.enableProgressiveCatalog();
return server;
});
const previous = server.server.onclose;
+22 -4
View File
@@ -18,6 +18,12 @@ import {
} from "./config/mcpInputSchemaProfile.js";
import { parseMcpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js";
import { parseMcpToolFamilies } from "./config/mcpToolFamilies.js";
import { parseMcpToolCatalogProfile } from "./config/mcpToolCatalogProfile.js";
import {
DISCOVER_TOOLS,
HOT_TOOL_NAMES,
progressiveCatalogIdentity,
} from "./catalogDiscovery.js";
import { contractsForToolFamilies } from "./contracts/toolFamilies.js";
import { promptsForToolFamilies } from "./contracts/promptToolFamilyScope.js";
import { err, ok } from "./domain/result.js";
@@ -600,8 +606,11 @@ const doctorExpectationForEnvironment = (
: undefined;
const contracts = contractsForToolFamilies(selection);
const promptContracts = promptsForToolFamilies(selection);
return {
catalog: createCatalogIdentity({
const catalogProfile = parseMcpToolCatalogProfile(
environment.REA_MCP_TOOL_CATALOG_PROFILE,
);
const progressive = catalogProfile.ok && catalogProfile.value === "hot-cold";
const catalog = createCatalogIdentity({
compactPresentation: compactPresentationForMcpInputSchemaProfile(
inputProfile.ok ? inputProfile.value : undefined,
),
@@ -610,8 +619,17 @@ const doctorExpectationForEnvironment = (
? (outputProfile.value ?? "full")
: "full",
promptContracts,
}),
expectedToolNames: contracts.map(({ name }) => name),
});
return {
catalog: progressive ? progressiveCatalogIdentity(catalog) : catalog,
expectedToolNames: progressive
? [
...contracts
.map(({ name }) => name)
.filter((name) => HOT_TOOL_NAMES.has(name)),
DISCOVER_TOOLS.name,
]
: contracts.map(({ name }) => name),
expectedPromptNames: promptContracts.map(({ name }) => name),
};
};
@@ -80,6 +80,7 @@ const fixture = async (
readonly failAfterTool?: boolean;
readonly maximumInputElements?: number;
readonly evidenceReferenceAlternative?: boolean;
readonly progressive?: boolean;
} = {},
) => {
const started = barrier();
@@ -146,6 +147,7 @@ const fixture = async (
options.maximumInputElements,
);
const client = new Client({ name: "deferred-fixture-client", version: "1" });
if (options.progressive) server.enableProgressiveCatalog();
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
@@ -165,6 +167,46 @@ const fixture = async (
};
};
it("loads cold definitions without initializing analysis and preserves canonical validation and identity", async () => {
const f = await fixture({ progressive: true });
const identity = f.server.catalogIdentity;
expect(identity.digests.combined_sha256).not.toBe(
catalog.identity.digests.combined_sha256,
);
expect(identity.counts.mcp_tools).toBe(2);
expect((await f.client.listTools()).tools.map(({ name }) => name)).toEqual([
"discover_tools",
]);
const changed = vi.fn();
f.client.setNotificationHandler("notifications/tools/list_changed", changed);
const discover = () =>
f.client.callTool({
name: "discover_tools",
arguments: { names: ["fixture"] },
});
expect((await discover()).isError).not.toBe(true);
expect(f.loads()).toBe(0);
expect(changed).toHaveBeenCalledTimes(1);
await discover();
expect(changed).toHaveBeenCalledTimes(1);
expect(
(await f.client.callTool({ name: "fixture", arguments: { n: 1.5 } }))
.isError,
).toBe(true);
expect(f.loads()).toBe(1);
expect(f.calls).not.toHaveBeenCalled();
expect(
(await f.client.callTool({ name: "fixture", arguments: {} }))
.structuredContent,
).toEqual({ n: 3 });
expect(f.server.catalogIdentity).toEqual(identity);
expect(changed).toHaveBeenCalledTimes(1);
const independent = await fixture({ progressive: true });
expect(
(await independent.client.listTools()).tools.map(({ name }) => name),
).toEqual(["discover_tools"]);
});
it("refuses excess input before loading a deferred runtime, then admits a valid call", async () => {
const f = await fixture({ maximumInputElements: 2 });
const result = await f.client.callTool({
+52 -2
View File
@@ -61,6 +61,8 @@ import {
TARGET_CONTENT_RESULT_META,
} from "./toolResultEncoding.js";
import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js";
import { DISCOVER_TOOLS } from "../catalogDiscovery.js";
import { ProgressiveToolCatalog } from "./ProgressiveToolCatalog.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { canReferenceInlineEvidence } from "./evidenceInputRegistration.js";
import {
@@ -298,6 +300,10 @@ export interface StandardToolRegistration {
/** Bind oversized MCP error recovery to this server's Evidence ledger. */
export class EvidenceMcpServerCore extends RequestTrackingMcpServer {
private readonly registrations = new Map<string, RegisteredTool>();
private progressiveCatalog: ProgressiveToolCatalog | undefined;
private catalogChangeDepth = 0;
private catalogChanged = false;
analysisSettings: AnalysisSettings | undefined;
/** Transport-owned Evidence uses the same lifetime as application writes. */
withAdmittedAnalysis: WithAdmittedAnalysis | undefined;
@@ -307,7 +313,42 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer {
get catalogIdentity(): ReturnType<typeof createCatalogIdentity> {
if (this.identity === undefined)
throw new Error("MCP catalog identity has not been configured");
return this.identity;
return this.progressiveCatalog?.identity ?? this.identity;
}
/** Install discovery after canonical registrations, before connecting. */
enableProgressiveCatalog(): void {
if (this.progressiveCatalog !== undefined) return;
const catalog = new ProgressiveToolCatalog(
this.registrations,
this.catalogIdentity,
(change) => {
this.catalogChangeDepth++;
try {
change();
} finally {
this.catalogChangeDepth--;
if (this.catalogChangeDepth === 0 && this.catalogChanged) {
this.catalogChanged = false;
super.sendToolListChanged();
}
}
},
() => this.delivery,
);
this.progressiveCatalog = catalog;
const { name, ...config } = DISCOVER_TOOLS;
// Transport discovery has no target or analysis lifecycle. Domain tools
// continue through registerTool and their original named handlers.
super.registerTool(name, config, (input, context) => {
context.mcpReq.signal.throwIfAborted();
return catalog.discover(input);
});
}
override sendToolListChanged(): void {
if (this.catalogChangeDepth > 0) this.catalogChanged = true;
else super.sendToolListChanged();
}
/** Attach the process-owned session only after lazy runtime construction. */
@@ -462,11 +503,20 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer {
config: StandardToolRegistration,
callback: ToolCallback<StandardSchemaWithJSON | undefined>,
): RegisteredTool {
return super.registerTool(name, config as never, callback as never);
const registered = super.registerTool(
name,
config as never,
callback as never,
);
this.registrations.set(name, registered);
return registered;
}
/** Preserve the SDK transport lifecycle while retaining oversized errors. */
override connect(transport: Transport): Promise<void> {
// HTTP reuses the analysis server after draining the previous transport.
// Reset visibility while disconnected, before the next initialize/list.
if (!this.isConnected()) this.progressiveCatalog?.reset();
return super.connect(
new ErrorEvidenceTransport(
transport,
+206
View File
@@ -0,0 +1,206 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, expect, it, vi } from "vitest";
import { z } from "zod";
import { EvidenceMcpServerCore } from "./EvidenceMcpServerCore.js";
import { ToolResultDelivery } from "./ToolResultDelivery.js";
const cleanup: Array<() => Promise<unknown>> = [];
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) await close();
});
const connect = async (
options: { replyBytes?: number; bulk?: boolean } = {},
) => {
const bulkNames = options.bulk
? Array.from(
{ length: 20 },
(_, index) => `bulk_${String(index).padStart(2, "0")}`,
)
: [];
const server = new EvidenceMcpServerCore(
{ name: "catalog-test", version: "1" },
{ capabilities: {} },
undefined,
new ToolResultDelivery(options.replyBytes ?? 1024 * 1024),
undefined,
"full",
{
counts: {
cli_commands: 0,
mcp_tools: 3 + bulkNames.length,
mcp_prompts: 0,
},
digests: {
tools_sha256: "tools",
prompts_sha256: "prompts",
combined_sha256: "combined",
},
},
);
const forbidden = vi.fn();
for (const name of [
"binary_session",
"alpha_sqlite",
"beta_sqlite",
"excluded_sqlite",
...bulkNames,
]) {
const registration = server.registerTool(
name,
{
description: `${name} SQLite database inspection`,
inputSchema: name.startsWith("bulk_")
? z.strictObject({
payload: z.string().describe("schema guidance ".repeat(64)),
})
: z.strictObject({}),
},
() => {
if (name === "excluded_sqlite") forbidden();
return { content: [] };
},
);
if (name === "excluded_sqlite") registration.disable();
}
server.enableProgressiveCatalog();
const client = new Client({ name: "catalog-test-client", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
cleanup.push(
() => server.close(),
() => client.close(),
);
await server.connect(serverTransport);
await client.connect(clientTransport);
const changed = vi.fn();
client.setNotificationHandler("notifications/tools/list_changed", changed);
const discover = (args: Record<string, unknown>) =>
client.callTool({ name: "discover_tools", arguments: args });
return { server, client, changed, discover, forbidden };
};
it("refuses an oversized maximum page before loading, then delivers every original contract in smaller pages", async () => {
const replyBytes = 8 * 1024;
const f = await connect({ replyBytes, bulk: true });
const initial = await f.client.listTools();
const refused = await f.discover({ query: "bulk", limit: 20 });
expect(refused.isError).toBe(true);
expect(Buffer.byteLength(JSON.stringify(refused))).toBeLessThanOrEqual(
replyBytes - 1024,
);
const text = refused.content.find((part) => part.type === "text")?.text;
expect(JSON.parse(text ?? "null")).toMatchObject({
error: {
code: "resource_constraint",
remediation: { action: expect.stringContaining("Reduce limit") },
},
});
expect(await f.client.listTools()).toEqual(initial);
expect(f.changed).not.toHaveBeenCalled();
const seen: string[] = [];
for (let offset = 0; offset < 20; offset++) {
const result = await f.discover({ query: "bulk", limit: 1, offset });
expect(result.isError).not.toBe(true);
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(
replyBytes - 1024,
);
expect(result.structuredContent).toMatchObject({
total_matches: 20,
next_offset: offset === 19 ? null : offset + 1,
loaded_count: offset + 3,
});
const page = JSON.parse(
result.content.find((part) => part.type === "text")?.text ?? "null",
);
const tool = page.tools[0];
seen.push(tool.name);
expect(
(await f.client.listTools()).tools.find(({ name }) => name === tool.name)
?.inputSchema,
).toEqual(tool.input_schema);
}
expect(new Set(seen).size).toBe(20);
expect(f.changed).toHaveBeenCalledTimes(20);
});
it("bounds an oversized unknown-name failure without changing visibility", async () => {
const replyBytes = 8 * 1024;
const f = await connect({ replyBytes });
const initial = await f.client.listTools();
const result = await f.discover({ names: ["unknown".repeat(4096)] });
expect(result.isError).toBe(true);
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(
replyBytes - 1024,
);
expect(await f.client.listTools()).toEqual(initial);
expect(f.changed).not.toHaveBeenCalled();
});
it("batches visibility changes, pages searches, and keeps excluded tools outside discovery", async () => {
const f = await connect();
const identity = f.server.catalogIdentity;
const first = await f.discover({ query: "sqlite", limit: 1 });
expect(first.structuredContent).toMatchObject({
inventory_count: 4,
loaded_count: 3,
total_matches: 3,
next_offset: 1,
tools: [{ name: "alpha_sqlite" }],
});
const next = await f.discover({ query: "sqlite", offset: 1, limit: 2 });
expect(next.structuredContent).toMatchObject({
loaded_count: 4,
next_offset: null,
tools: [{ name: "beta_sqlite" }, { name: "binary_session" }],
});
expect(f.changed).toHaveBeenCalledTimes(2);
await f.discover({ names: ["alpha_sqlite", "beta_sqlite"] });
expect(f.changed).toHaveBeenCalledTimes(2);
expect(
(await f.discover({ query: "missing_word" })).structuredContent,
).toMatchObject({ total_matches: 0, tools: [], next_offset: null });
expect((await f.discover({ names: ["excluded_sqlite"] })).isError).toBe(true);
await expect(
f.client.callTool({ name: "excluded_sqlite", arguments: {} }),
).rejects.toThrow("disabled");
expect(f.forbidden).not.toHaveBeenCalled();
expect(f.server.catalogIdentity).toEqual(identity);
});
it("loads exact names atomically and emits one notification for a batch", async () => {
const f = await connect();
expect(
(await f.discover({ names: ["alpha_sqlite", "missing"] })).isError,
).toBe(true);
expect((await f.client.listTools()).tools.map(({ name }) => name)).toEqual([
"binary_session",
"discover_tools",
]);
expect(f.changed).not.toHaveBeenCalled();
const result = await f.discover({
names: ["beta_sqlite", "alpha_sqlite", "alpha_sqlite"],
});
expect(result.structuredContent).toMatchObject({
tools: [{ name: "beta_sqlite" }, { name: "alpha_sqlite" }],
total_matches: 2,
});
expect(f.changed).toHaveBeenCalledTimes(1);
});
it.each([
{ names: [] },
{ names: ["alpha_sqlite"], query: "sqlite" },
{ names: ["alpha_sqlite"], offset: 1 },
{ limit: 21 },
{ offset: -1 },
{ names: ["alpha_sqlite"], extra: true },
])(
"rejects malformed discovery before changing visibility: %j",
async (args) => {
const f = await connect();
expect((await f.discover(args)).isError).toBe(true);
expect((await f.client.listTools()).tools).toHaveLength(2);
expect(f.changed).not.toHaveBeenCalled();
},
);
+163
View File
@@ -0,0 +1,163 @@
import type { RegisteredTool } from "@modelcontextprotocol/server";
import type { z } from "zod";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { ownedJsonObjectSchema } from "../domain/jsonValue.js";
import { encodeToolResult } from "./toolResultEncoding.js";
import type { ToolResultDelivery } from "./ToolResultDelivery.js";
import {
DISCOVER_TOOLS,
HOT_TOOL_NAMES,
progressiveCatalogIdentity,
type CatalogIdentity,
} from "../catalogDiscovery.js";
/** Per-connection visibility; canonical SDK handles retain validation and authority. */
export class ProgressiveToolCatalog {
readonly identity: CatalogIdentity;
private readonly tools: ReadonlyMap<
string,
{ registered: RegisteredTool; description: string; search: string }
>;
constructor(
registrations: ReadonlyMap<string, RegisteredTool>,
identity: CatalogIdentity,
private readonly batchChanges: (change: () => void) => void,
private readonly delivery: () => ToolResultDelivery,
) {
this.identity = progressiveCatalogIdentity(identity);
// Disabled family registrations are outside the discoverable scope. Never
// let discovery re-enable a tool the caller excluded from this server.
this.tools = new Map(
[...registrations]
.filter(([, tool]) => tool.enabled)
.map(([name, registered]) => [
name,
{
registered,
description: (registered.description ?? "").slice(0, 240),
search:
`${name} ${name.replaceAll("_", " ")} ${registered.description ?? ""}`.toLowerCase(),
},
]),
);
this.reset();
}
/** A reused HTTP server starts each new transport with the hot inventory. */
reset(): void {
this.batchChanges(() => {
for (const [name, { registered }] of this.tools) {
if (registered.enabled && !HOT_TOOL_NAMES.has(name))
registered.disable();
}
});
}
discover(input: z.infer<typeof DISCOVER_TOOLS.inputSchema>) {
const names =
input.names === undefined ? undefined : [...new Set(input.names)];
const unknown = names?.filter((name) => !this.tools.has(name)) ?? [];
if (unknown.length > 0)
return this.delivery().toErrorToolResult(
new AnalysisInputError(
DISCOVER_TOOLS.name,
{
userMessage:
"Unknown or excluded tool names. No tools were loaded.",
remediationAction:
"Search with query to find exact in-scope names.",
conformsToInputContract: true,
},
unknown.map((name) => ({
path: ["names"],
reason: "invalid_value",
message: name,
})),
),
false,
);
const words = (input.query ?? "")
.toLowerCase()
.split(/\s+/u)
.filter(Boolean);
const matches =
names ??
[...this.tools]
.filter(([, tool]) => words.every((word) => tool.search.includes(word)))
.map(([name]) => name)
.sort((left, right) => {
const score = (name: string) =>
words.filter((word) => name.includes(word)).length;
return (
score(right) - score(left) ||
(left < right ? -1 : left > right ? 1 : 0)
);
});
const page =
names ?? matches.slice(input.offset, input.offset + input.limit);
const selected = page.map((name) => ({ name, ...this.tools.get(name)! }));
const result: z.infer<typeof DISCOVER_TOOLS.outputSchema> = {
profile: "hot-cold",
inventory_count: this.tools.size + 1,
loaded_count:
1 +
[...this.tools.values()].filter(({ registered }) => registered.enabled)
.length +
selected.filter(({ registered }) => !registered.enabled).length,
total_matches: matches.length,
next_offset:
names === undefined && input.offset + page.length < matches.length
? input.offset + page.length
: null,
tools: selected.map(({ name, description, registered }) => ({
name,
description,
input_schema: registered.inputSchema?.["~standard"].jsonSchema.input({
target: "draft-2020-12",
}) ?? { type: "object" },
})),
next_step:
selected.length === 0
? "No matches. Try fewer or different task keywords, or browse without query."
: "Call the returned tool by its original name using input_schema. Your client must refresh tools/list first; if it cannot, reconnect with REA_MCP_TOOL_CATALOG_PROFILE=full.",
};
// This reply borrows read-only SDK schema projections for synchronous
// encoding; validate the new reply without copying their complete trees.
const payload = ownedJsonObjectSchema.parse(result);
const delivery = this.delivery();
const encoded = encodeToolResult(payload, delivery.resultBudgetBytes);
if (!encoded.ok)
return delivery.toErrorToolResult(
new AnalysisResourceConstraintError(
DISCOVER_TOOLS.name,
"transport",
"The discovery page exceeds the reply budget. No tools were loaded.",
{
result_budget_bytes: delivery.resultBudgetBytes,
response_bytes_at_least: encoded.bytesAtLeast,
constraint: encoded.constraint,
},
{
remediationAction:
"Reduce limit or request fewer exact names, then retry. For one oversized input contract, reconnect with REA_MCP_INPUT_SCHEMA_PROFILE=compact.",
},
),
false,
);
// Visibility changes only after both reply representations fit. A failed
// page can be retried without silently enabling unreported definitions.
this.batchChanges(() => {
for (const { registered } of selected) {
if (!registered.enabled) registered.enable();
}
});
return {
content: [{ type: "text" as const, text: encoded.text }],
structuredContent: payload,
};
}
}
+6
View File
@@ -43,6 +43,7 @@ import { registerFridaTools } from "./registerFridaTools.js";
import { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import { catalogIdentityForToolFamilies } from "../catalogIdentity.js";
import { parseMcpToolFamilies } from "../config/mcpToolFamilies.js";
import { parseMcpToolCatalogProfile } from "../config/mcpToolCatalogProfile.js";
import { createToolResultDelivery } from "./toolResult.js";
import type { ToolResultDelivery } from "./ToolResultDelivery.js";
import { parseMcpResponseBudget } from "../config/mcpResponseBudget.js";
@@ -171,6 +172,10 @@ export const createServer = (
const compactInputPresentation =
selectCompactInputSchemaPresentation(environment);
const selectedOutputSchemaProfile = outputSchemaProfile(environment);
const catalogProfile = parseMcpToolCatalogProfile(
environment.REA_MCP_TOOL_CATALOG_PROFILE,
);
if (!catalogProfile.ok) throw catalogProfile.error;
const advertisedToolFamilies = selectAdvertisedToolFamilies(environment);
const providerEnvironment = captureProviderEnvironment(
options.providerEnvironment,
@@ -810,6 +815,7 @@ export const createServer = (
},
});
registerGuidedPrompts(server, analysis, session);
if (catalogProfile.value === "hot-cold") server.enableProgressiveCatalog();
return server;
};
+2
View File
@@ -195,6 +195,7 @@ it.each([
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000",
REA_HOPPER_STARTUP_TIMEOUT_MS: "120000",
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256",
REA_REGEX_MATCHING_DEADLINE_MS: "10000",
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256",
@@ -235,6 +236,7 @@ it.each([
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000",
REA_HOPPER_STARTUP_TIMEOUT_MS: "120000",
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256",
REA_REGEX_MATCHING_DEADLINE_MS: "10000",
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256",
+121 -1
View File
@@ -20,6 +20,10 @@ import { TOOL_FAMILY_DEFINITIONS } from "../../../src/contracts/toolFamilies.js"
import { evidenceSchema } from "../../../src/domain/evidence.js";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { SERVER_START_FAILED } from "../../../src/main/messages.js";
import { HOT_TOOL_NAMES } from "../../../src/catalogDiscovery.js";
import { DatabaseSync } from "node:sqlite";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { AnySchemaObject } from "ajv";
const connect = async (
mode: "legacy" | "auto",
@@ -56,8 +60,117 @@ const connect = async (
return { client, stderr: () => stderr };
};
it.each([
{ mode: "legacy", environment: {} },
{ mode: "auto", environment: {} },
{ mode: "legacy", environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" } },
{
mode: "auto",
environment: {
REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact",
REA_MCP_TOOL_FAMILIES: "session,application,artifact",
},
},
] as const)(
"discovers and executes cold tools with $mode and $environment",
async ({ mode, environment }) => {
const directory = await createTestTempDirectory("rea-cold-sqlite-");
const path = join(directory, "app.db");
const database = new DatabaseSync(path);
try {
database.exec(
"CREATE TABLE catalog_probe (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO catalog_probe VALUES (7, 'pelican');",
);
} finally {
database.close();
}
const { client, stderr } = await connect(mode, {
...environment,
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
});
const initial = await client.listTools();
expect(new Set(initial.tools.map(({ name }) => name))).toEqual(
new Set([...HOT_TOOL_NAMES, "discover_tools"]),
);
for (const tool of initial.tools) {
const ajv = new Ajv2020({ strict: false });
expect(ajv.validateSchema(tool.inputSchema)).toBe(true);
if (tool.outputSchema !== undefined)
expect(ajv.validateSchema(tool.outputSchema as AnySchemaObject)).toBe(
true,
);
}
const call = async (name: string, args: Record<string, unknown>) => {
if (environment.REA_MCP_OUTPUT_SCHEMA_PROFILE === "off") {
const definition = (await client.listTools()).tools.find(
(tool) => tool.name === name,
);
if (definition === undefined)
throw new Error(`Not advertised: ${name}`);
return client.callTool(
{ name, arguments: args },
{ toolDefinition: definition },
);
}
return artifactMcpCall(client, { name, arguments: args });
};
const invalid = await call("discover_tools", {
names: ["inspect_sqlite_database", "missing_tool"],
});
expect(invalid.isError).toBe(true);
expect(await client.listTools()).toEqual(initial);
const loaded = await call("discover_tools", {
names: ["list_unknowns", "list_unknowns"],
});
expect(loaded.structuredContent).toMatchObject({
loaded_count: 7,
total_matches: 1,
tools: [{ name: "list_unknowns" }],
});
expect((await call("list_unknowns", {})).isError).not.toBe(true);
const materialized = await client.listTools();
expect(materialized.tools).toHaveLength(7);
expect(
materialized.tools.find(({ name }) => name === "inspect_sqlite_database"),
).toBeUndefined();
const found = await call("discover_tools", { query: "sqlite", limit: 1 });
expect(found.structuredContent).toMatchObject({
total_matches: 1,
tools: [{ name: "inspect_sqlite_database" }],
});
const advertisedSqlite = (await client.listTools()).tools.find(
({ name }) => name === "inspect_sqlite_database",
);
expect(found.structuredContent).toMatchObject({
tools: [{ input_schema: advertisedSqlite?.inputSchema }],
});
const result = await call("inspect_sqlite_database", {
path,
table: "catalog_probe",
columns: ["id", "name"],
row_limit: 1,
});
expect(result.isError).not.toBe(true);
expect(JSON.stringify(result.structuredContent)).toContain("pelican");
expect(JSON.stringify(result.structuredContent)).toContain("evidence_id");
expect((await client.listTools()).tools).toHaveLength(8);
if ("REA_MCP_TOOL_FAMILIES" in environment) {
expect(
(await call("discover_tools", { names: ["current_address"] })).isError,
).toBe(true);
expect((await client.listTools()).tools).toHaveLength(8);
}
expect(stderr()).toBe("");
},
);
it.each([
{ name: "deferred", environment: {}, eager: false },
{
name: "hot/cold deferred",
environment: { REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" },
eager: false,
},
{
name: "compact deferred",
environment: { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" },
@@ -113,8 +226,15 @@ registerHooks({
const { client, stderr } = await connect("legacy", environment, [
`--import=${hook}`,
]);
const discovery = await client.listTools();
let discovery = await client.listTools();
await client.listPrompts();
if (environment.REA_MCP_TOOL_CATALOG_PROFILE === "hot-cold") {
await client.callTool({
name: "discover_tools",
arguments: { names: ["list_unknowns"] },
});
discovery = await client.listTools();
}
expect(await loadedModules()).toEqual(
new Set(eager ? ["entrypoint", ...modules] : ["entrypoint"]),
);
+29
View File
@@ -1,4 +1,5 @@
import { contractsForToolFamilies } from "../../../src/contracts/toolFamilies.js";
import { progressiveCatalogIdentity } from "../../../src/catalogDiscovery.js";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { execFile, spawn } from "node:child_process";
@@ -19,6 +20,7 @@ import {
runProductionMcpDoctorCli,
} from "../../../src/mcpDoctor.js";
import {
DEFAULT_CATALOG_IDENTITY,
FULL_CATALOG_IDENTITY,
catalogIdentityForToolFamilies,
createCatalogIdentity,
@@ -195,6 +197,33 @@ const catalogProfiles = [
},
];
it("recognizes a hot/cold initial catalog and its inventory identity", async () => {
const result = await runProductionMcpDoctor({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
environment: { ...process.env, REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" },
});
expect(result.healthy).toBe(true);
expect(result.inventory?.tools).toMatchObject({
expected: 6,
observed: 6,
missing: [],
unexpected: [],
});
expect(result.identity?.observed_catalog_digest).toBe(
progressiveCatalogIdentity(DEFAULT_CATALOG_IDENTITY).digests
.combined_sha256,
);
const environment = {
...process.env,
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
};
const identity = progressiveCatalogIdentity(DEFAULT_CATALOG_IDENTITY);
await assertStatusCatalogIdentity(environment, identity);
await assertCliCatalogIdentity(environment, identity);
});
describe("production MCP doctor", () => {
it("reuses immutable compiled profile digests and preserves custom-budget identity", async () => {
// Exercise the generated module with Node, outside Vitest's source loader.
+52 -1
View File
@@ -6,7 +6,10 @@ import { describe, expect, onTestFinished } from "vitest";
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
import { expectForeignHostRefused } from "../../support/http/rawRequest.js";
import { startMcpHttpProcess } from "../../support/mcp/streamableHttpProcess.js";
import {
startMcpHttpProcess,
startListeningProcess,
} from "../../support/mcp/streamableHttpProcess.js";
import { processTest } from "../../support/process/processFixture.js";
import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js";
@@ -44,6 +47,54 @@ const expectIdentity = async ({
};
describe("rea mcp --http", () => {
processTest(
"keeps hot/cold visibility within its HTTP session and refreshes named contracts",
async ({ processes }) => {
const started = await startListeningProcess(
processes,
["mcp", "--http", "--port", "0"],
{ REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" },
);
const first = await openSession(started.url, "hot-cold-first");
expect((await first.client.listTools()).tools).toHaveLength(6);
let changed = 0;
first.client.setNotificationHandler(
"notifications/tools/list_changed",
() => {
changed++;
},
);
const discovered = await first.client.callTool({
name: "discover_tools",
arguments: { names: ["list_unknowns"] },
});
expect(discovered.isError).not.toBe(true);
const { toolDefinition } = await advertisedMcpToolSchemas(
first.client,
"list_unknowns",
);
expect(changed).toBe(1);
expect(
(
await first.client.callTool(
{ name: "list_unknowns", arguments: {} },
{ toolDefinition },
)
).isError,
).not.toBe(true);
await first.transport.terminateSession();
await first.client.close();
const second = await openSession(started.url, "hot-cold-second");
expect(
(await second.client.listTools()).tools.map(({ name }) => name),
).not.toContain("list_unknowns");
expect((await second.client.listTools()).tools).toHaveLength(6);
await expectIdentity(second);
await second.transport.terminateSession();
},
HTTP_BOUNDARY_TIMEOUT_MS,
);
processTest(
"serves initialize, tools/list, a tool call and DELETE, then keeps serving later clients",
async ({ processes }) => {