mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 06:46:10 +00:00
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:
1 parent
ad518cff65
commit
0beedcd712
22 files changed
+956
-42
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }],
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -217,6 +217,8 @@ export const runDeferred = async (
|
||||
}),
|
||||
config.mcpMaxInputElements,
|
||||
);
|
||||
if (config.mcpToolCatalogProfile === "hot-cold")
|
||||
server.enableProgressiveCatalog();
|
||||
return server;
|
||||
});
|
||||
const previous = server.server.onclose;
|
||||
|
||||
+29
-11
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user