perf(mcp)!: defer retained views in progressive catalogs (#9151)

Keep full schemas and canonical analysis unchanged while loading the cross-domain view through existing named discovery. Qualify real recent-tool workflows with token ledgers, latency, CPU, family RSS and retained-follow-up controls.

BREAKING CHANGE: The opt-in hot-cold catalog no longer preloads inspect_analysis_view. Discover it before calling it, batch with its producer when needed, or use the unchanged default full profile.
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-11-06 22:44:56 +08:00
1 parent cfa7ebb020
commit bacc1e3846
9 files changed
+1695 -12

No files matched your search

+11
View File
@@ -111,6 +111,17 @@ 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.
Analysis tools, including `inspect_analysis_view`, load on demand. For example,
load `inspect_api_contract` and supply its `view` to answer an initial OpenAPI
or GraphQL question directly. When later questions need retained facts, load
`inspect_analysis_view` and use the complete `parent_evidence_id`; source files
need not be reopened. If both tools are needed from the start, request both
names in one `discover_tools` batch to avoid another catalog refresh. The same
pattern applies to selected Playwright trace and endpoint-comparison results.
This defers unused schema delivery and client validator preparation; it does
not reduce parsing work, alter Evidence or shorten requested facts. Clients
without catalog refresh should keep the default `full` profile.
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:
+7 -1
View File
@@ -1901,7 +1901,13 @@ 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`.
`get_evidence_bundle`, and the adapter's `discover_tools`.
`inspect_analysis_view` is loaded on demand like other analysis tools. When a
follow-up needs retained facts, include it in `discover_tools.names`; if the
producer and follow-up are known together, load both in one batch. Producers
that accept `view` can answer a selected first question without loading the
cross-domain view contract. Complete output schemas and canonical result
validation are preserved when each tool loads.
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
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,66 @@
# Progressive catalog costs for recent analysis tools
## Decision and compatibility
Load `inspect_analysis_view` on demand in the opt-in `hot-cold` profile. Its cross-domain output contract is unused when a producer's inline `view` answers the question. The existing catalog owner already handles exact-name discovery, visibility, notifications, scope and identity; no new cache, analysis pipeline or protocol is added.
This changes initial hot/cold visibility: clients that assumed the view was always loaded must discover it before calling it. When follow-ups are already known, discover the producer and view in one batch. On the pinned measurement baseline, the default `full` catalog is exactly unchanged: 241 tools, 4,257,925 serialized bytes, identical names, schemas, annotations and descriptions. Complete schemas, canonical validation, Evidence IDs, provenance, unknowns and requested facts remain intact.
## Pinned verification
Baseline: `1480a7c72e601f4444e21ecd50118c1afa774209`; candidate is that checkout with the `src/catalogDiscovery.ts` policy change recorded in the [JSON receipt](recent-tools-progressive-catalog-2026-11-06.json). Node 24.22.1 and MCP client/server 2.3.1, Linux x64. No target execution or browser launch occurs during these offline tasks.
Three alternating before/after suite repetitions use sequential fresh MCP servers, Client and AJV instances for six cases. The Node harness/SDK code is shared across the six cases in a repetition, so later cases have warm code. Filesystem caches were not flushed; these are repeat measurements, not a cold-disk qualification. Capture acquisition, fixture generation and builds preceded profiling. Two separate alternating batched controls use a fresh Node harness per trial. All Node processes use normal JIT and 1 GiB heaps. Stock SDK output validation/caching is retained without per-call tool-definition overrides.
The SDK 2.3.1 cache compiles validators for the complete discovered list on first use. With `application,session,artifact` families and full output schemas, the initial hot/cold catalog drops from 781,713 to 90,374 bytes. The five inline-only tasks reduce cumulative validator preparation from roughly 5–8 seconds to 0.7–1.4 seconds. This is a client/catalog cost reduction, not faster artifact parsing.
## Answer latency and token estimates
Per-case times below include discovery, analysis and oracle assertions through the verified answer; final close is accounted for in the suite totals below. Values are medians of three repetitions, except the two-repetition batched control.
Token estimates use `tiktoken==0.14.0`, `o200k_base`. The conservative column counts each distinct visible tool input definition once, recorded server instructions, a fixed question, every tool argument and every text reply. It includes discovery calls. It is a proxy, not model billing: actual hosts may prune tools, replay catalogs or render structured results differently. No model requests were made.
| Completed question | Answer seconds before → after | Incremental input/text token proxy before → after |
| ---------------------------------------------- | ----------------------------: | ------------------------------------------------: |
| OpenAPI: Petstore + GitHub GET declarations | 13.80 → 6.46 | 9,669 → 6,336 |
| GraphQL: SWAPI Film + GitHub Repository fields | 13.42 → 7.56 | 11,898 → 8,565 |
| Playwright: small + large network events | 12.15 → 6.75 | 11,946 → 8,613 |
| Static JS / HAR: unobserved endpoint | 10.61 → 5.31 | 8,296 → 4,963 |
| OpenWrt: two FIT component inventories | 10.21 → 6.28 | 11,468 → 8,135 |
| Later-discovered retained follow-ups | 10.78 → 12.79 | 14,373 → 14,583 |
| Batched producer + retained follow-ups | 12.96 → 13.17 | 14,375 → 14,503 |
The five inline-only tasks improve in this cohort; the conservative token proxy falls 28–40%. The retained cases eventually need the same definitions and **do not establish a token reduction** under incremental loading. Discovering the view later adds one tool call and one catalog refresh; the shared-harness cohort also shows slower answer latency. Batching avoids that extra discovery/refresh, but the cold-client timing ranges overlap: no retained-workflow speedup is established.
If each refreshed catalog is instead counted once in full, the snapshot/text proxy falls further, including in the retained cases. The SDK-wire proxy additionally includes output schemas and duplicated `structuredContent`; these are transport costs and must not be presented as model input. Both ledgers and all ranges are in the JSON receipt.
## CPU, memory and cleanup
Each entire six-case suite completes all server/client closes and is measured with two CPU affinity, a 4 GiB sampled process-family RSS guard, 200 ms samples and a 20-minute deadline. No compared run hit a guard. These are suite totals, not per-case attribution.
| Six-case suite, median | Before | After |
| ------------------------------- | -------: | -------: |
| Elapsed | 70.62 s | 46.18 s |
| User + system CPU | 75.32 s | 47.74 s |
| Sampled process-family peak RSS | 1.11 GiB | 0.97 GiB |
The RSS statistic sums process RSS and can count shared pages more than once. Host-wide pressure, inference CPU/memory, cold-disk performance and sustained large-page generic-validator JIT behavior are unmeasured. No universal provider, model-token or memory improvement is claimed.
## Correctness oracle and limits
- OpenAPI selections agree with the original JSON path/method declarations in small Petstore and large GitHub specifications.
- GraphQL names and declaration lines come from an independent source-line oracle for SWAPI and GitHub SDL; declarations must lie inside returned source ranges, including preceding SDL descriptions, with matching source digests.
- Every selected trace network line, method, URL and status agrees with raw original ZIP `trace.network` JSON. Both original small and large Playwright recordings are used.
- Endpoint comparison reuses authenticated static JavaScript/browser-HAR Evidence from #9090 and identifies `/api/admin/export` as not observed in that capture. It does not establish endpoint absence outside the capture.
- OpenWrt 24.10.0/24.10.1 ABT ASR3000 recovery FITs retain the previously independently checked kernel/initrd changes, identical DTB and separately changed outer `.` from #9143. No decompression, device execution or native provider analysis.
- Analysis Evidence digests match across every paired run. The public stdio regression also removes its owned source file after import, discovers the view, verifies the complete advertised output schema, queries retained facts and exports the complete source Evidence. Repeat discovery emits no extra notification.
Input sizes/digests and profiler/receipt digests are recorded in JSON. Original qualification sources and scope are in #9090, #9104, #9127, #9140 and #9143. Raw harnesses and captures remain local outside Git; exact real-data timing reproduction requires those matching external fixtures. The public regression is self-contained.
An initial harness attempt used an incorrect assertion that a field declaration line must equal its source range start. SDL descriptions precede some declarations. That attempt failed, is retained separately and is excluded; the corrected oracle checks containment. The intended public regression fails on the base because the view is initially loaded, then passes after the policy change.
No default catalog change, weakened output validation, shortened IDs, omitted Evidence, guessed absence, additional parsed-result cache or parser speedup is part of this change.
## Integration verification
After rebasing onto `fbe7532fabb78de46bd00db845c1147b5325ad22`, the public workflow passed again and all six real-workflow Evidence digests matched the qualification above. An unrelated upstream managed schema change makes the default catalog 4,257,977 bytes; a separate same-base comparison confirms exact equality before and after this policy change. The repeated performance cohort remains pinned to its original baseline; the additional integration run is recorded separately in JSON.
+2 -1
View File
@@ -2,12 +2,13 @@ import { createHash } from "node:crypto";
import { z } from "zod";
import type { createCatalogIdentity } from "./catalogIdentity.js";
// Retained views are loaded like producers: their full cross-domain output
// contract is unnecessary until a task actually queries retained analysis.
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
@@ -0,0 +1,98 @@
import { readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
import { apiContractSchema } from "../../../src/domain/apiContract/apiContract.js";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { toolOutputSchemaWithMetadata } from "../../../src/contracts/toolSchemaMetadata.js";
it("loads retained analysis views only when needed, preserving complete schemas, source facts and Evidence", async () => {
const root = await createTestTempDirectory("rea-progressive-api-");
const path = join(root, "api.json");
await writeFile(
path,
JSON.stringify({
openapi: "3.1.0",
info: { title: "Fixture", version: "1" },
paths: { "/items": { get: { operationId: "listItems" } } },
}),
);
const { client } = await connectLocalToolsMcp({
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
REA_MCP_TOOL_FAMILIES: "application,session",
});
const names = async () =>
(await client.listTools()).tools.map((tool) => tool.name);
expect(await names()).not.toContain("inspect_analysis_view");
const changed = vi.fn();
client.setNotificationHandler("notifications/tools/list_changed", changed);
const call = async (name: string, args: Record<string, unknown>) => {
const result = await client.callTool({ name, arguments: args });
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
return result;
};
await call("discover_tools", { names: ["inspect_api_contract"] });
expect(await names()).toContain("inspect_api_contract");
const view = {
kind: "api-contract",
collection: "operations",
paths: ["/items"],
fields: ["method", "path"],
};
const first = parseEvidence(
(await call("inspect_api_contract", { path, view })).structuredContent,
);
const selected = analysisViewResultSchema.parse(first.normalized_result);
expect(selected).toMatchObject({
kind: "api-contract",
items: [{ method: "GET", path: "/items", state: "declared" }],
});
expect(await names()).not.toContain("inspect_analysis_view");
expect(changed).toHaveBeenCalledTimes(1);
await rm(path);
await call("discover_tools", {
names: ["inspect_analysis_view", "export_evidence_bundle"],
});
const loaded = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_analysis_view",
);
expect(loaded?.outputSchema).toEqual(
toolOutputSchemaWithMetadata(toolContract("inspect_analysis_view"))[
"~standard"
].jsonSchema.output({ target: "draft-2020-12" }),
);
expect(changed).toHaveBeenCalledTimes(2);
const source = {
kind: "retained-evidence",
evidence_id: selected.parent_evidence_id,
};
const follow = await call("inspect_analysis_view", { source, view });
expect(parseEvidence(follow.structuredContent)).toEqual(first);
const bundlePath = join(root, "complete.json");
await call("export_evidence_bundle", { path: bundlePath });
const bundle = evidenceBundleSchema.parse(
JSON.parse(await readFile(bundlePath, "utf8")),
);
const parent = bundle.records.find(
(record) => record.evidence_id === selected.parent_evidence_id,
);
expect(parent).toBeDefined();
const report = apiContractSchema.parse(parent?.normalized_result);
expect(report.operations).toMatchObject([
{
method: "GET",
path: "/items",
declared_id: "listItems",
source_pointer: "/paths/~1items/get",
},
]);
expect(follow.structuredContent).toHaveProperty("evidence_links", [
selected.parent_evidence_id,
]);
await call("discover_tools", { names: ["inspect_analysis_view"] });
expect(changed).toHaveBeenCalledTimes(2);
});
+8 -4
View File
@@ -123,13 +123,13 @@ it.each([
names: ["list_unknowns", "list_unknowns"],
});
expect(loaded.structuredContent).toMatchObject({
loaded_count: 7,
loaded_count: initial.tools.length + 1,
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).toHaveLength(initial.tools.length + 1);
expect(
materialized.tools.find(({ name }) => name === "inspect_sqlite_database"),
).toBeUndefined();
@@ -153,12 +153,16 @@ it.each([
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);
expect((await client.listTools()).tools).toHaveLength(
initial.tools.length + 2,
);
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((await client.listTools()).tools).toHaveLength(
initial.tools.length + 2,
);
}
expect(stderr()).toBe("");
},
+6 -3
View File
@@ -1,5 +1,8 @@
import { contractsForToolFamilies } from "../../../src/contracts/toolFamilies.js";
import { progressiveCatalogIdentity } from "../../../src/catalogDiscovery.js";
import {
HOT_TOOL_NAMES,
progressiveCatalogIdentity,
} from "../../../src/catalogDiscovery.js";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { execFile, spawn } from "node:child_process";
@@ -206,8 +209,8 @@ it("recognizes a hot/cold initial catalog and its inventory identity", async ()
});
expect(result.healthy).toBe(true);
expect(result.inventory?.tools).toMatchObject({
expected: 6,
observed: 6,
expected: HOT_TOOL_NAMES.size + 1,
observed: HOT_TOOL_NAMES.size + 1,
missing: [],
unexpected: [],
});
+6 -3
View File
@@ -56,7 +56,11 @@ describe("rea mcp --http", () => {
{ REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" },
);
const first = await openSession(started.url, "hot-cold-first");
expect((await first.client.listTools()).tools).toHaveLength(6);
const initialNames = (await first.client.listTools()).tools.map(
({ name }) => name,
);
expect(initialNames).not.toContain("inspect_analysis_view");
expect(initialNames).not.toContain("list_unknowns");
let changed = 0;
first.client.setNotificationHandler(
"notifications/tools/list_changed",
@@ -87,8 +91,7 @@ describe("rea mcp --http", () => {
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);
).toEqual(initialNames);
await expectIdentity(second);
await second.transport.terminateSession();
},