From 0beedcd712e39a7d93e4558ee56fd596eaa22838 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:27:31 +0800 Subject: [PATCH] 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. --- docs/client-configuration.md | 27 +++ docs/mcp-contracts.md | 45 +++- docs/tool-design.md | 7 +- scripts/lib/artifact-e2e.mjs | 36 +-- src/application/DirectAnalysisStatus.ts | 23 +- .../SetupRegistrationEnvironment.ts | 1 + src/catalogDiscovery.ts | 110 ++++++++++ src/config/environment.ts | 2 + src/config/mcpToolCatalogProfile.ts | 23 ++ src/config/parseConfig.ts | 3 + src/config/types.ts | 2 + src/main/deferredRuntime.ts | 2 + src/mcpDoctor.ts | 40 +++- src/server/DeferredEvidenceMcpServer.test.ts | 42 ++++ src/server/EvidenceMcpServerCore.ts | 54 ++++- src/server/ProgressiveToolCatalog.test.ts | 206 ++++++++++++++++++ src/server/ProgressiveToolCatalog.ts | 163 ++++++++++++++ src/server/createServer.ts | 6 + src/setupEnvironment.test.ts | 2 + tests/boundary/mcp/deferredRuntime.test.ts | 122 ++++++++++- tests/boundary/mcp/mcpDoctor.test.ts | 29 +++ tests/boundary/mcp/streamableHttpMcp.test.ts | 53 ++++- 22 files changed, 956 insertions(+), 42 deletions(-) create mode 100644 src/catalogDiscovery.ts create mode 100644 src/config/mcpToolCatalogProfile.ts create mode 100644 src/server/ProgressiveToolCatalog.test.ts create mode 100644 src/server/ProgressiveToolCatalog.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 73d7c387d4..38250bf6a1 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -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, diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index adaf8956df..58e4b6b651 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -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 diff --git a/docs/tool-design.md b/docs/tool-design.md index 4a7f84a0e4..df084789c0 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -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 diff --git a/scripts/lib/artifact-e2e.mjs b/scripts/lib/artifact-e2e.mjs index 15d5915fb3..572a268ef0 100644 --- a/scripts/lib/artifact-e2e.mjs +++ b/scripts/lib/artifact-e2e.mjs @@ -7,9 +7,21 @@ 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 readDefinitions = (client) => + client.listTools().then(({ tools }) => { + const definitions = new Map(); + for (const tool of tools) { + assert.ok( + !definitions.has(tool.name), + `Duplicate MCP tool: ${tool.name}`, + ); + definitions.set(tool.name, tool); + } + return definitions; + }); const advertisedTool = async (client, name) => { const connection = client.getServerVersion(); @@ -18,23 +30,19 @@ const advertisedTool = async (client, name) => { if (binding?.connection !== connection) { binding = { connection, - definitions: client.listTools().then(({ tools }) => { - const definitions = new Map(); - for (const tool of tools) { - assert.ok( - !definitions.has(tool.name), - `Duplicate MCP tool: ${tool.name}`, - ); - definitions.set(tool.name, tool); - } - return definitions; - }), + definitions: readDefinitions(client), }; toolBindings.set(client, binding); } let definitions; + const snapshot = binding.definitions; try { - definitions = await binding.definitions; + definitions = await snapshot; + if (!definitions.has(name)) { + if (binding.definitions === snapshot) + binding.definitions = readDefinitions(client); + definitions = await binding.definitions; + } assert.equal( client.getServerVersion(), connection, diff --git a/src/application/DirectAnalysisStatus.ts b/src/application/DirectAnalysisStatus.ts index 8214f5b203..94056d8df5 100644 --- a/src/application/DirectAnalysisStatus.ts +++ b/src/application/DirectAnalysisStatus.ts @@ -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, }), }; }, diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 335c86488a..c909059442 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -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", diff --git a/src/catalogDiscovery.ts b/src/catalogDiscovery.ts new file mode 100644 index 0000000000..1b4b31a64e --- /dev/null +++ b/src/catalogDiscovery.ts @@ -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 = 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; +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, + }), + }, + }; +}; diff --git a/src/config/environment.ts b/src/config/environment.ts index ef9b25739c..d07e3d1bbc 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -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) diff --git a/src/config/mcpToolCatalogProfile.ts b/src/config/mcpToolCatalogProfile.ts new file mode 100644 index 0000000000..05960b82f1 --- /dev/null +++ b/src/config/mcpToolCatalogProfile.ts @@ -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; + +export const parseMcpToolCatalogProfile = ( + value: string | undefined, +): Result => { + 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 }], + }), + ); +}; diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index e401d43405..5fe9781fdb 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -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 ? {} diff --git a/src/config/types.ts b/src/config/types.ts index 1abacfd785..b2e3b724cf 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; diff --git a/src/main/deferredRuntime.ts b/src/main/deferredRuntime.ts index 6df57038e2..e8c981ac4a 100644 --- a/src/main/deferredRuntime.ts +++ b/src/main/deferredRuntime.ts @@ -217,6 +217,8 @@ export const runDeferred = async ( }), config.mcpMaxInputElements, ); + if (config.mcpToolCatalogProfile === "hot-cold") + server.enableProgressiveCatalog(); return server; }); const previous = server.server.onclose; diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 841e013cb3..35e61bb7a4 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -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,18 +606,30 @@ const doctorExpectationForEnvironment = ( : undefined; const contracts = contractsForToolFamilies(selection); const promptContracts = promptsForToolFamilies(selection); + 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, + ), + contracts, + outputSchemaProfile: outputProfile.ok + ? (outputProfile.value ?? "full") + : "full", + promptContracts, + }); return { - catalog: createCatalogIdentity({ - compactPresentation: compactPresentationForMcpInputSchemaProfile( - inputProfile.ok ? inputProfile.value : undefined, - ), - contracts, - outputSchemaProfile: outputProfile.ok - ? (outputProfile.value ?? "full") - : "full", - promptContracts, - }), - expectedToolNames: contracts.map(({ name }) => name), + 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), }; }; diff --git a/src/server/DeferredEvidenceMcpServer.test.ts b/src/server/DeferredEvidenceMcpServer.test.ts index 425ac88557..44b0ff6e93 100644 --- a/src/server/DeferredEvidenceMcpServer.test.ts +++ b/src/server/DeferredEvidenceMcpServer.test.ts @@ -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({ diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index accf187fa8..2d451eb78b 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -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(); + 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 { 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, ): 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 { + // 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, diff --git a/src/server/ProgressiveToolCatalog.test.ts b/src/server/ProgressiveToolCatalog.test.ts new file mode 100644 index 0000000000..bfde259bc3 --- /dev/null +++ b/src/server/ProgressiveToolCatalog.test.ts @@ -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> = []; +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) => + 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(); + }, +); diff --git a/src/server/ProgressiveToolCatalog.ts b/src/server/ProgressiveToolCatalog.ts new file mode 100644 index 0000000000..b55fa64886 --- /dev/null +++ b/src/server/ProgressiveToolCatalog.ts @@ -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, + 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) { + 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 = { + 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, + }; + } +} diff --git a/src/server/createServer.ts b/src/server/createServer.ts index e016e7f7b6..2eb4ef63b2 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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; }; diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 5a5ed8d9ad..1a9efce788 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -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", diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index 3a5e7ba962..6e64ee1d61 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -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) => { + 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"]), ); diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 005be263e4..c45846280d 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -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. diff --git a/tests/boundary/mcp/streamableHttpMcp.test.ts b/tests/boundary/mcp/streamableHttpMcp.test.ts index 11150c8ce8..38c28d65a1 100644 --- a/tests/boundary/mcp/streamableHttpMcp.test.ts +++ b/tests/boundary/mcp/streamableHttpMcp.test.ts @@ -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 }) => {