feat(mcp): support compact output schema advertisements (#7218)

Add opt-in bounded output-schema advertisements while preserving the full default, canonical server validation and complete structured content. Verify real stdio discovery under a 1 MiB receive cap, all 5,367 libcrypto export coordinates, complete Evidence parity, whole-task serialization estimates and resource bounds.

Fixes #2512. Integrates #7054 by Akshad Jogi with original authorship retained.

Co-authored-by: Akshad Jogi <akshadjogi@gmail.com>
This commit is contained in:
gensokyo-devandAkshad Jogi authored and GitHub committed 2026-10-26 17:59:51 +08:00
1 parent e596a48251
commit 9085222ca1
22 files changed
+1831 -771

No files matched your search

+6
View File
@@ -96,6 +96,12 @@ complete advertised field guidance and nested validation details. Both profiles
keep the full tool set and canonical server validation. See
"Compact input schema profile" in the MCP contracts guide.
Complete output schemas are advertised by default. If your client caps the
complete `tools/list` frame, set `REA_MCP_OUTPUT_SCHEMA_PROFILE=compact` to
bound each advertised output schema, or `off` to omit output schemas entirely.
Both settings keep canonical server-side validation and complete structured
results. See "Output schemas" in the MCP contracts guide.
### Ask your agent
```text
+7 -4
View File
@@ -208,10 +208,13 @@ and Claude Code reports that autocompact is thrashing. To avoid this:
The sizes above come from REA's own `tools/list`. A live Claude Code session
against DeepSeek's API is unverified.
For a client whose stdio reader rejects the complete catalog frame, add
`"REA_MCP_OUTPUT_SCHEMA_PROFILE": "off"` to REA's `env` object and restart the
client. This omits only advertised output schemas; REA still validates complete
structured results against its canonical contracts. See [MCP output schema
For a client whose stdio reader rejects the complete catalog frame, set
`"REA_MCP_OUTPUT_SCHEMA_PROFILE": "compact"` to bound every advertised output
schema, or `"off"` to omit them, in REA's `env` object, then restart the client.
Both keep canonical result validation and complete structured results; `compact`
keeps a widened root shape and `off` advertises nothing. On catalogs large
enough that full input schemas alone approach the frame limit, also keep the
input profile at its `compact` default. See [MCP output schema
profiles](mcp-contracts.md#output-schemas).
## Kimi Code
+29 -13
View File
@@ -1367,21 +1367,37 @@ identity. Explicit schema IDs and relative external reference bases are
preserved. This keeps every tool and validation rule in discovery and does not
change the SDK's catalog invalidation or availability checks.
The server identity's tool-schema digest includes both the canonical output
contracts and the selected output-schema profile. `full` and `off` therefore
have distinct identities even though `off` keeps the same canonical output
validation on the server.
The server identity's tool-schema digest includes the selected output-schema
profile and the advertised form that profile produces. `full`, `compact`, and
`off` therefore have distinct identities. The `compact` digest covers the
bounded schemas clients receive; `off` keeps the canonical output contracts in
the digest while advertising nothing.
Some stdio hosts reject a complete `tools/list` frame before they load any
tools. The opt-in `REA_MCP_OUTPUT_SCHEMA_PROFILE=off` setting omits `outputSchema`
from the advertised catalog for hosts with a small frame limit (the measured
pi-web-ui failure and catalog breakdown are in [#2512](https://github.com/morluto/rea/issues/2512)).
The default `full` profile is unchanged. With `off`, REA validates successful
structured results against the same named canonical tool contract before
delivery; it still returns complete `structuredContent` and text results.
Only client-side schema discovery and validation are omitted, so hosts using
this profile should rely on REA's validation and retain their usual response
size limits.
tools. The opt-in `REA_MCP_OUTPUT_SCHEMA_PROFILE=compact` setting bounds every
advertised output schema to 4,096 bytes; `off` omits `outputSchema` entirely
(the measured pi-web-ui failure and catalog breakdown are in
[#2512](https://github.com/morluto/rea/issues/2512)). The default `full` profile
is unchanged. Both settings keep the same named canonical tool contract for
server-side result validation, and both still return complete
`structuredContent` and text results.
`compact` keeps the complete validation structure when it fits the budget.
Otherwise it advertises the root property shape, first-level reference-resolved
types, first-level guidance, and the root's `required`/`additionalProperties`
constraints, with nested validation detail elided. Local references are inlined
within the budget, so the advertised form is self-contained and omits the
canonical contract's content-bound `$id`. The projection only widens: a valid
canonical result still satisfies the advertised schema, but a client validating
against it can accept a result the canonical contract rejects. REA still
validates every result before delivery. On catalogs large enough that full
input schemas alone approach the frame limit, keep the input profile at its
`compact` default as well; with full input schemas the advertisement is
dominated by the input side.
Only client-side schema discovery and validation are reduced or omitted, so
hosts using these profiles should rely on REA's validation and retain their
usual response size limits.
### Function comparison limitations
@@ -0,0 +1,123 @@
# Compact MCP output advertisements
## Question and scope
Can an SDK client with a 1 MiB receive buffer discover REA and finish an
export investigation without weakening canonical result validation or losing
Evidence? This verifies #2512 using the opt-in compact output advertisement
from #7054. The default output profile remains `full`; inputs are `compact`.
The implementation was integrated onto main `18dc78fa43175b9628b45b731df06c9321c3f730`.
Measured runtime revision: `650d483b98564d8dbe87d55ce6a77ec410608136`.
Both profiles use that same runtime, rather than two different analyzers.
The complete schema remains the server's validator; the bounded advertisement
is a widening and does not replace the delivered `structuredContent`.
## Completed public workflow and independent oracle
`mcp-output-profile.mjs` starts the packaged CLI over real stdio with the
installed MCP SDK client, an isolated account and an explicit receive cap.
It records initialize/discovery, two native export summaries (cold/warm),
selected first/last exports, complete Evidence publication, and a second
discovery after materialization. Named advertised schemas validate the calls.
After closing the server, it authenticates the complete exported bundle and
checks every export name/address against GNU `nm`. The complete sets of names
must agree; selected rows must retain their original source indices.
Linux x64, Node 24.22.1, npm 11.16.0. Real target:
`/usr/lib/x86_64-linux-gnu/libcrypto.so.3`, SHA-256
`aa01e614af634bc7c8b678c9250b5c4f613c61989bacf0a5d6aed6570e7dcabd`.
All 5,367 export names/addresses were corroborated. Both profiles produced the
same authenticated parent Evidence ID and complete-record digest. GNU `nm`
corroborates export coordinates, not behavioral equivalence.
| Measurement | Full output | Compact output |
| -------------------------------------------------- | ----------: | -------------: |
| Discovered tools | 229 | 229 |
| Serialized catalog result bytes | 3,639,545 | 554,792 |
| Catalog cl100k token estimate | 932,629 | 116,962 |
| Complete task wire estimate, one discovery | 939,165 | 123,501 |
| Executed wire estimate, including second discovery | 1,871,825 | 240,494 |
| Discovery after connection, ms | 292 | 1,886 |
| Repeated discovery, ms | 333 | 49 |
| Whole harness elapsed, s | 9.86 | 6.18 |
| Whole harness CPU, s | 11.96 | 7.66 |
| Sampled process-family peak RSS, GiB | 1.04 | 0.62 |
The SDK's real 1,048,576-byte receive cap rejects the full catalog before any
analysis call. The compact run succeeds under that cap; the successful full
control uses the SDK's normal 10 MiB cap. A small `/usr/bin/ls` pilot also
completed under 1 MiB, with all 15 export names corroborated. Neither public
workflow uses a fake provider or launches the inspected executable.
Whole harness costs include the client, server, complete publication, offline
bundle parsing/oracle, and cleanup. Each run has a fresh server; the second
inspection/discovery is warm. OS file caches were not flushed. Two CPU affinity
slots, a 3 GiB Node heap request and an 8 GiB process-family RSS guard bound the
runs. RSS is the sum sampled every 100 ms: shared pages can be counted twice
and short peaks can be missed. No run hit a resource guard or OOM, and cleanup
succeeded. One pair does not establish a general speed or memory improvement;
compact startup takes the eager path and its initial discovery is slower.
## What the token counts establish
Estimates use `tiktoken`'s `cl100k_base` over serialized JSON messages, including
initialization, request arguments, every needed reply and discovery. The
one-discovery value excludes only the explicit repeated catalog consistency
probe; the executed total includes it. Local complete-bundle bytes stay outside
the wire. These are transport serialization estimates, not actual model usage.
The names/descriptions/input-schema projection is byte-identical across both
profiles: 56,909 estimated tokens. A host forwarding only those fields gains no
model-context reduction from this output-profile setting. A host retaining
output contracts, a typed Code Mode catalog, or the entire wire has a different
cost. Native host forwarding and live model behavior were not measured here.
The demonstrated benefit is successful bounded-frame discovery with complete
analysis, plus the measured reduction in serialized output advertisements.
## Reproduction and retained failures
Build once, then use fresh output directories:
```sh
npm run build:cached
node experiments/analysis-efficiency/mcp-output-profile.mjs \
--runtime . --profile full --target /usr/lib/x86_64-linux-gnu/libcrypto.so.3 \
--output /tmp/rea-output-full
node experiments/analysis-efficiency/mcp-output-profile.mjs \
--runtime . --profile compact --target /usr/lib/x86_64-linux-gnu/libcrypto.so.3 \
--receive-bytes 1048576 --output /tmp/rea-output-compact
```
Repeat the full command with `--receive-bytes 1048576` as a negative control;
expect a nonzero exit, a receive-buffer error in `report.json`, and no analysis
calls. Reports retain the catalog, transcript, complete bundle and server log.
The resource guard/profiler is external to this driver; choose host-appropriate
limits when reproducing. Token estimates can be reproduced from the saved
messages with the tokenizer above, counting each `message` once.
Two initial small pilot attempts used incomplete source selectors in this new
driver. REA correctly returned `invalid_request`; both failed receipts were
retained with successful cleanup. The driver was corrected to use the canonical
`source: {kind: "retained-evidence", evidence_id}` before the completed runs.
Initial focused checks ran concurrent files under two CPU slots. Two full
catalog compilation assertions exceeded their 30-second test timeouts; 46 other
MCP cases and 14 source cases passed. The representative assertion passed alone
in 22 seconds, and the complete file passed with one worker, but another run
after the main rebase exceeded the deadlines again. CPU contention alone did
not fully explain the variability.
A bounded probe over the five largest output schemas isolated AJV generated-code
optimization: compile-only checks took 4.75 seconds with optimization and 2.22
seconds without it. The schema-validity fixture now disables that code-generation
optimization because it never executes these validators. It still validates the
declared dialect, compiles every schema, isolates each reference namespace, and
checks malformed/unresolved references. Actual SDK validators retain their normal
options. Test deadlines and schema/reference assertions remain unchanged.
After rebasing onto main `7d36d172a`, both complete real workflows passed again
at integration runtime `f735451d9`, retaining the same export parent Evidence
and complete-record digest across profiles. The 1 MiB compact cap still passes.
The 20 schema/profile cases also pass with the default two workers after the
compile-only optimization change; check:fast and repository formatting pass.
@@ -0,0 +1,229 @@
/** Public stdio investigation with real ELF exports and a selected receive cap. */
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { finished } from "node:stream/promises";
import { pathToFileURL } from "node:url";
import { parseArgs, promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
const { values } = parseArgs({
options: Object.fromEntries(
["runtime", "output", "profile", "target", "receive-bytes"].map((key) => [
key,
{ type: "string" },
]),
),
});
for (const key of ["runtime", "output", "target"]) {
assert.ok(values[key], `${key} is required`);
values[key] = resolve(values[key]);
}
assert.ok(["full", "compact"].includes(values.profile));
const receiveBytes = Number(values["receive-bytes"] ?? 10 * 1024 * 1024);
assert.ok(Number.isSafeInteger(receiveBytes) && receiveBytes > 0);
await mkdir(values.output);
const report = {
profile: values.profile,
receive_bytes: receiveBytes,
runtime: values.runtime,
target: values.target,
calls: [],
};
const transcript = [];
let account, client, transport, serverLog, serverLogFinished;
let phase = "initialize";
const began = performance.now();
try {
account = await mkdtemp(join(tmpdir(), "rea-output-profile-"));
report.target_sha256 = createHash("sha256")
.update(await readFile(values.target))
.digest("hex");
report.revision = (
await promisify(execFile)("git", [
"-C",
values.runtime,
"rev-parse",
"HEAD",
])
).stdout.trim();
transport = new StdioClientTransport({
command: process.execPath,
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
cwd: values.runtime,
maxBufferSize: receiveBytes,
env: {
PATH: process.env.PATH,
HOME: account,
USERPROFILE: account,
NODE_OPTIONS: process.env.NODE_OPTIONS ?? "--max-old-space-size=3072",
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_MCP_OUTPUT_SCHEMA_PROFILE: values.profile,
},
stderr: "pipe",
});
const start = transport.start.bind(transport);
transport.start = async () => {
const onerror = transport.onerror;
transport.onerror = (cause) => {
(report.transport_errors ??= []).push(String(cause));
onerror?.(cause);
};
const receive = transport.onmessage;
transport.onmessage = (message) => {
transcript.push({ direction: "response", phase, message });
receive?.(message);
};
await start();
};
const send = transport.send.bind(transport);
transport.send = async (message) => {
transcript.push({ direction: "request", phase, message });
await send(message);
};
serverLog = createWriteStream(join(values.output, "server.log"));
serverLogFinished = finished(serverLog);
serverLogFinished.catch(() => {});
transport.stderr.pipe(serverLog);
client = new Client({
name: "rea-output-profile-investigation",
version: "1",
});
await client.connect(transport);
phase = "discovery-cold";
const discoveryBegan = performance.now();
const catalog = await client.listTools({}, { timeout: 15000 });
report.discovery_ms = performance.now() - discoveryBegan;
report.catalog_bytes = Buffer.byteLength(JSON.stringify(catalog));
report.tools = catalog.tools.length;
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
const call = async (name, args, label) => {
phase = label;
const at = performance.now();
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 90000, toolDefinition: definitions.get(name) },
);
assert.notEqual(
result.isError,
true,
`${name}: ${JSON.stringify(result).slice(0, 500)}`,
);
report.calls.push({ name, phase, elapsed_ms: performance.now() - at });
return result.structuredContent;
};
const first = await call(
"inspect_native_exports",
{ path: values.target, detail: "summary" },
"exports-cold",
);
const repeated = await call(
"inspect_native_exports",
{ path: values.target, detail: "summary" },
"exports-warm",
);
const parentId = first.normalized_result.parent_evidence_id;
assert.equal(repeated.normalized_result.parent_evidence_id, parentId);
report.parent_evidence_id = parentId;
const count = first.normalized_result.summary.export_count;
assert.ok(count > 1);
const page = await call(
"inspect_analysis_view",
{
source: { kind: "retained-evidence", evidence_id: parentId },
view: {
kind: "native-exports",
collection: "exports",
indices: [0, count - 1],
fields: ["name"],
limit: 2,
},
},
"selected-exports",
);
assert.deepEqual(
page.normalized_result.items.map((item) => item.source_index),
[0, count - 1],
);
const bundlePath = join(values.output, "complete-evidence.json");
await call("export_evidence_bundle", { path: bundlePath }, "complete-export");
phase = "discovery-warm";
const warmAt = performance.now();
assert.deepEqual(await client.listTools(), catalog);
report.warm_discovery_ms = performance.now() - warmAt;
// Release the server ledger before parsing the complete exported records.
await client.close();
await transport.close();
const { parseEvidenceBundle } = await import(
pathToFileURL(join(values.runtime, "dist/domain/evidenceBundle.js")).href
);
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(bundlePath, "utf8")),
);
const parent = bundle.records.find(
(record) => record.evidence_id === parentId,
);
assert.ok(parent);
const rows = parent.normalized_result.exports;
assert.equal(rows.length, count);
for (const row of page.normalized_result.items) {
assert.equal(row.name, rows[row.source_index].name);
}
const nm = await promisify(execFile)(
"nm",
[
"--dynamic",
"--defined-only",
"--without-symbol-versions",
"--format=posix",
values.target,
],
{ maxBuffer: 16 * 1024 * 1024 },
);
const symbols = nm.stdout
.trim()
.split("\n")
.map((line) => line.split(/\s+/u));
const names = new Set(symbols.map(([name]) => name));
const coordinates = new Set(
symbols.map(([name, , value]) => `${name}:${BigInt(`0x${value}`)}`),
);
for (const row of rows)
assert.ok(
coordinates.has(`${row.name}:${BigInt(row.value)}`),
`GNU nm did not corroborate ${row.name} at ${row.value}`,
);
const observed = new Set(rows.map((row) => row.name));
for (const name of names)
assert.ok(observed.has(name), `REA omitted GNU nm export ${name}`);
report.export_count = count;
report.nm_unique_names = names.size;
report.facts_sha256 = createHash("sha256")
.update(JSON.stringify(parent))
.digest("hex");
report.outcome = "passed";
} catch (cause) {
report.outcome = "failed";
report.failure = String(cause);
process.exitCode = 1;
} finally {
report.elapsed_ms = performance.now() - began;
const published = await finalizeRetainedQueryReceipt({
report,
client,
transport,
account,
serverLog,
serverLogFinished,
output: values.output,
transcript,
});
if (!published) process.exitCode = 1;
}
+1 -1
View File
@@ -13,7 +13,7 @@ const { createCatalogDigestProfiles } = await import(
pathToFileURL(join(compiledRoot, "catalogIdentity.js")).href
);
// Retain only hashes, with every shared record frozen. Schemas remain owned by
// the compiled contracts and are converted once for all four identities.
// the compiled contracts and are converted once for all six identities.
const profiles = Object.entries(createCatalogDigestProfiles())
.map(
([input, outputs]) =>
+37 -7
View File
@@ -16,7 +16,15 @@ import {
presentAdvertisedInputSchema,
type CompactInputSchemaPresentation,
} from "./contracts/compactInputSchema.js";
import type { McpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js";
import {
presentAdvertisedOutputSchema,
type CompactOutputSchemaPresentation,
} from "./contracts/compactOutputSchema.js";
import {
COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES,
compactPresentationForMcpOutputSchemaProfile,
type McpOutputSchemaProfile,
} from "./config/mcpOutputSchemaProfile.js";
import {
COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
compactPresentationForMcpInputSchemaProfile,
@@ -57,15 +65,28 @@ const createToolCatalog = (contracts: readonly ToolContract[]) =>
const presentToolCatalog = (
catalog: ReturnType<typeof createToolCatalog>,
presentation: CompactInputSchemaPresentation | undefined,
outputPresentation: CompactOutputSchemaPresentation | undefined = undefined,
) =>
presentation === undefined
presentation === undefined && outputPresentation === undefined
? catalog
: catalog.map((tool) => ({
...tool,
input_schema: presentAdvertisedInputSchema(
tool.input_schema,
presentation,
),
...(presentation === undefined
? {}
: {
input_schema: presentAdvertisedInputSchema(
tool.input_schema,
presentation,
),
}),
...(outputPresentation === undefined
? {}
: {
output_schema: presentAdvertisedOutputSchema(
tool.output_schema,
outputPresentation,
),
}),
}));
const createPromptCatalog = (contracts: readonly PromptContract[]) =>
@@ -124,6 +145,7 @@ export const createCatalogDigests = (
presentToolCatalog(
createToolCatalog(sortByName(contracts)),
compactPresentation,
compactPresentationForMcpOutputSchemaProfile(outputSchemaProfile),
),
createPromptCatalog(promptContracts),
outputSchemaProfile,
@@ -137,7 +159,7 @@ export type CatalogDigestProfiles = Readonly<
>
>;
/** Build-time identities share schema conversion across the four supported profiles. */
/** Build-time identities share schema conversion across the six supported profiles. */
export const createCatalogDigestProfiles = (
contracts: readonly ToolContract[] = TOOL_CONTRACTS,
promptContracts: readonly PromptContract[] = PROMPT_CONTRACTS,
@@ -147,8 +169,16 @@ export const createCatalogDigestProfiles = (
budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
});
const prompts = createPromptCatalog(promptContracts);
const compactOutput: CompactOutputSchemaPresentation = {
budgetBytes: COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES,
};
const outputProfiles = (catalog: typeof full) => ({
full: digestCatalogs(catalog, prompts, "full"),
compact: digestCatalogs(
presentToolCatalog(catalog, undefined, compactOutput),
prompts,
"compact",
),
off: digestCatalogs(catalog, prompts, "off"),
});
return { full: outputProfiles(full), compact: outputProfiles(compact) };
+22 -2
View File
@@ -3,15 +3,35 @@ import { z } from "zod";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
/**
* Per-schema budget for compact output advertisements. Output contracts are
* larger than input contracts across the catalog, and the complete frame is
* what a frame-capped host rejects. Measured on the complete contract catalog
* with the default compact input profile, 4 KiB bounds the output
* advertisement to ~276 KiB and the complete `tools/list` response to
* ~577 KiB, leaving margin under the one-megabyte frame reported in #2512. A
* larger budget admits substantially more full contracts; a much smaller one
* drops first-level property guidance for nearly every tool.
*/
export const COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES = 4 * 1024;
/** Select whether tools/list carries the large output contracts. */
export const mcpOutputSchemaProfileSchema = z.enum(["full", "off"]);
export const mcpOutputSchemaProfileSchema = z.enum(["full", "compact", "off"]);
export type McpOutputSchemaProfile = z.infer<
typeof mcpOutputSchemaProfileSchema
>;
/** Resolve the compact output advertisement, preserving every other choice. */
export const compactPresentationForMcpOutputSchemaProfile = (
profile: McpOutputSchemaProfile | undefined,
): { readonly budgetBytes: number } | undefined =>
profile === "compact"
? { budgetBytes: COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES }
: undefined;
const profileConstraint =
'REA_MCP_OUTPUT_SCHEMA_PROFILE must be "full" (default) or "off". The off profile omits output schemas from tools/list while REA validates every successful result against its canonical tool contract and returns complete structured content.';
'REA_MCP_OUTPUT_SCHEMA_PROFILE must be "full" (default), "compact", or "off". The compact profile advertises every output schema within a 4,096-byte budget; the off profile omits output schemas. Both keep canonical server-side validation and complete structured content.';
/** Parse the output schema advertisement selection at server composition. */
export const parseMcpOutputSchemaProfile = (
+21 -600
View File
@@ -1,48 +1,18 @@
import {
JsonSchemaReferences,
type JsonSchemaScope,
} from "./jsonSchemaReferences.js";
import {
schemaArrays,
schemaChildren,
schemaMaps,
schemaLiteralValues,
} from "./jsonSchemaKeywords.js";
COMPACT_SCHEMA_MAX_DEPTH,
hasGatewayIncompatibleShape,
inlineWithinBudget,
reduceToShallowProperties,
schemaDepth,
serializedBytes,
stripAnnotations,
typeArrayItemUnions,
} from "./compactJsonSchema.js";
// Kimi expands schema-local references before sending tools to Moonshot.
// Compact advertisements must fit after expansion, independently of client
// reference handling. Canonical server-side validation remains unchanged.
/** Annotation keys that carry guidance but no validation semantics. */
const annotationKeys = new Set(["description", "examples", "default", "title"]);
const definitionKeys = new Set(["$defs", "definitions"]);
// An empty schema accepts every JSON value. Kimi otherwise infers string.
const jsonValueTypes = [
"null",
"boolean",
"object",
"array",
"number",
"string",
];
// An empty item schema accepts every JSON value, including arbitrarily nested
// arrays, while satisfying clients that require `items` for array types.
// Gemini rejects `items` beside a type list; the compact catalog test fails
// if a tool would advertise this presentation.
const jsonValueSchema = () => ({ type: [...jsonValueTypes], items: {} });
const enumValueTypes = (values: readonly unknown[]): readonly string[] => [
...new Set(
values.map((value) => {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
if (typeof value === "object") return "object";
return typeof value;
}),
),
];
export type AdvertisedInputSchemaPresentation = "inline" | "reduced";
/** Budget selected by the caller for compact MCP input-schema presentation. */
@@ -55,561 +25,9 @@ export interface CompactedInputSchema {
readonly presentation: AdvertisedInputSchemaPresentation;
}
const isSchemaObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const serializedBytes = (value: unknown): number =>
Buffer.byteLength(JSON.stringify(value), "utf8");
const schemaDepth = (value: unknown, depth = 0): number => {
if (Array.isArray(value))
return Math.max(
depth,
...value.map((child) => schemaDepth(child, depth + 1)),
);
if (!isSchemaObject(value)) return depth;
return Math.max(
depth,
...Object.values(value).map((child) => schemaDepth(child, depth + 1)),
);
};
/** Strict clients commonly reject advertised schemas deeper than ten nodes. */
const COMPACT_SCHEMA_MAX_DEPTH = 10;
/** Rewrite every schema-valued position; literal data stays opaque. */
const rebuildSchemaPositions = (
node: unknown,
rebuild: (child: unknown) => unknown,
): unknown => {
if (Array.isArray(node)) return node.map((child) => rebuild(child));
if (!isSchemaObject(node)) return node;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node)) {
if (schemaLiteralValues.has(key)) {
out[key] = value;
continue;
}
if (schemaMaps.has(key) && isSchemaObject(value)) {
out[key] = Object.fromEntries(
Object.entries(value).map(([name, child]) => [name, rebuild(child)]),
);
continue;
}
if (schemaArrays.has(key) && Array.isArray(value)) {
out[key] = value.map((child) => rebuild(child));
continue;
}
if (schemaChildren.has(key)) {
out[key] = Array.isArray(value)
? value.map((child) => rebuild(child))
: rebuild(value);
continue;
}
out[key] = value;
}
return out;
};
/**
* Drop annotation prose from every subschema while preserving all validation
* keywords. The root keeps its own description because the root-union
* projection encodes accepted input groups there; the root's echoed literal
* examples leave with the rest of the annotation prose.
*/
const stripAnnotations = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const rebuilt = rebuildSchemaPositions(
schema,
stripChildAnnotations,
) as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rebuilt)) {
if (annotationKeys.has(key) && key !== "description") continue;
out[key] = value;
}
return out;
};
const stripChildAnnotations = (child: unknown): unknown => {
const rebuilt = rebuildSchemaPositions(child, stripChildAnnotations);
if (!isSchemaObject(rebuilt)) return rebuilt;
const kept: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rebuilt))
if (!annotationKeys.has(key)) kept[key] = value;
return kept;
};
/** Stop serialization at the budget before allocating an expanded schema. */
const inlineWithinBudget = (
root: Record<string, unknown>,
budgetBytes: number,
): Record<string, unknown> | undefined => {
const references = new JsonSchemaReferences(root);
const identity = Object.fromEntries(
["$id", "$anchor", "$schema"].flatMap((key) =>
Object.hasOwn(root, key) ? [[key, root[key]]] : [],
),
);
const identityBytes =
Object.keys(identity).length === 0 ? 0 : serializedBytes(identity) - 2;
if (identityBytes > budgetBytes) return undefined;
let resolved = true;
function* serialize(
value: unknown,
position: "schema" | "map" | "data",
scope: JsonSchemaScope | undefined,
active: ReadonlyMap<JsonSchemaScope, ReadonlySet<string>>,
): Generator<string> {
if (Array.isArray(value)) {
yield "[";
for (const [index, child] of value.entries()) {
if (index > 0) yield ",";
yield* serialize(child, position, scope, active);
}
yield "]";
} else if (isSchemaObject(value)) {
const currentScope =
position === "schema" ? references.enter(value, scope) : scope;
if (
position === "schema" &&
(currentScope === undefined ||
currentScope.dialect !== references.rootScope.dialect ||
[
"$dynamicRef",
"$recursiveRef",
"$dynamicAnchor",
"$recursiveAnchor",
].some((key) => Object.hasOwn(value, key)))
) {
resolved = false;
return;
}
if (position === "schema" && Object.keys(value).length === 0) {
yield JSON.stringify(jsonValueSchema());
return;
}
if (position === "schema" && typeof value.$ref === "string") {
const target = references.resolve(currentScope, value.$ref);
if (
target.kind !== "target" ||
currentScope === undefined ||
active.get(currentScope)?.has(value.$ref)
) {
resolved = false;
return;
}
const {
$ref,
$id: _id,
$anchor: _anchor,
$schema: _schema,
...siblings
} = value;
const nextActive = new Map(active);
nextActive.set(
currentScope,
new Set([...(active.get(currentScope) ?? []), $ref]),
);
// Draft 7 ignores siblings, while clients such as Ajv also apply them.
// Do not label either interpretation an equivalent inline advertisement.
if (
currentScope.dialect === "draft-07" &&
Object.keys(siblings).length > 0
) {
resolved = false;
return;
}
const expanded =
Object.keys(siblings).length === 0
? target.value
: { allOf: [target.value, siblings] };
yield* serialize(expanded, "schema", currentScope, nextActive);
return;
}
yield "{";
let first = true;
for (const [key, child] of Object.entries(value)) {
if (
position === "schema" &&
(definitionKeys.has(key) ||
key === "$id" ||
key === "$anchor" ||
key === "$schema")
)
continue;
if (!first) yield ",";
first = false;
yield JSON.stringify(key) + ":";
// `not: {}` forbids a value; no client infers a type for a value it
// must omit, and the any-value list would place `items` beside a
// non-array type, which Gemini function declarations reject.
if (
position === "schema" &&
key === "not" &&
isSchemaObject(child) &&
Object.keys(child).length === 0
) {
yield "{}";
continue;
}
const childPosition =
position === "map"
? "schema"
: position === "schema" && schemaMaps.has(key)
? "map"
: position === "schema" &&
(schemaArrays.has(key) || schemaChildren.has(key))
? "schema"
: "data";
yield* serialize(child, childPosition, currentScope, active);
}
yield "}";
} else {
yield JSON.stringify(value);
}
}
const chunks: string[] = [];
let bytes = identityBytes;
for (const chunk of serialize(
root,
"schema",
references.rootScope,
new Map(),
)) {
bytes += Buffer.byteLength(chunk, "utf8");
if (bytes > budgetBytes || !resolved) return undefined;
chunks.push(chunk);
}
if (!resolved) return undefined;
const expanded: unknown = JSON.parse(chunks.join(""));
if (!isSchemaObject(expanded)) return undefined;
// Every emitted dependency is now static and reference-free. Nested resource
// identifiers/anchors can leave without duplicating identities at call sites.
const schema = { ...identity, ...expanded };
return serializedBytes(schema) <= budgetBytes ? schema : undefined;
};
/** Preserve every possible type, including referenced and nullable unions. */
const detectedTypes = (
schema: unknown,
references: JsonSchemaReferences,
inherited: JsonSchemaScope | undefined,
active: ReadonlySet<object> = new Set(),
): readonly string[] | undefined => {
if (!isSchemaObject(schema)) return undefined;
const scope = references.enter(schema, inherited);
if (scope === undefined) return undefined;
if (typeof schema.$ref === "string") {
if (active.has(schema)) return undefined;
const target = references.resolve(scope, schema.$ref);
return target.kind === "target"
? detectedTypes(
target.value,
references,
scope,
new Set([...active, schema]),
)
: undefined;
}
if (typeof schema.type === "string") return [schema.type];
if (
Array.isArray(schema.type) &&
schema.type.every((type) => typeof type === "string")
)
return schema.type;
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
const enumTypes = enumValueTypes(schema.enum);
if (enumTypes.every((type) => jsonValueTypes.includes(type)))
return enumTypes;
}
const alternatives = Array.isArray(schema.anyOf)
? schema.anyOf
: Array.isArray(schema.oneOf)
? schema.oneOf
: [];
if (alternatives.length === 0) return undefined;
const types = alternatives.map((branch) =>
detectedTypes(branch, references, scope, active),
);
if (types.some((type) => type === undefined)) return undefined;
return [...new Set(types.flatMap((type) => type ?? []))];
};
interface ScopedSchema {
readonly schema: Record<string, unknown>;
readonly scope: JsonSchemaScope;
}
/**
* Collect the item schema of every array alternative. Tuples, boolean item
* schemas and unresolved references are unknown because one item type would
* not describe every accepted element.
*/
const arrayItemAlternatives = (
schema: unknown,
references: JsonSchemaReferences,
inherited: JsonSchemaScope | undefined,
active: ReadonlySet<object> = new Set(),
): readonly ScopedSchema[] | undefined => {
if (!isSchemaObject(schema)) return undefined;
const scope = references.enter(schema, inherited);
if (scope === undefined) return undefined;
if (typeof schema.$ref === "string") {
if (active.has(schema)) return undefined;
const target = references.resolve(scope, schema.$ref);
return target.kind === "target"
? arrayItemAlternatives(
target.value,
references,
scope,
new Set([...active, schema]),
)
: undefined;
}
const types =
typeof schema.type === "string"
? [schema.type]
: Array.isArray(schema.type)
? schema.type
: undefined;
if (types !== undefined) {
if (!types.includes("array")) return [];
return isSchemaObject(schema.items) && schema.prefixItems === undefined
? [{ schema: schema.items, scope }]
: undefined;
}
const alternatives = Array.isArray(schema.anyOf)
? schema.anyOf
: Array.isArray(schema.oneOf)
? schema.oneOf
: [];
if (alternatives.length === 0) return undefined;
const nested = alternatives.map((branch) =>
arrayItemAlternatives(branch, references, scope, active),
);
return nested.some((items) => items === undefined)
? undefined
: nested.flatMap((items) => items ?? []);
};
/** Nested arrays beyond this level advertise an unconstrained item schema. */
const REDUCED_ITEM_MAX_DEPTH = 3;
/**
* Advertise the element types the canonical item schemas accept. Gemini
* function declarations permit `items` only beside the single `array` type,
* so an element that may be an array alongside other types, or whose type is
* unknown, uses the empty schema: it accepts every element and still gives
* clients that require `items` an item schema.
*/
const reducedItemSchema = (
alternatives: readonly ScopedSchema[] | undefined,
references: JsonSchemaReferences,
depth = 1,
): Record<string, unknown> => {
if (
alternatives === undefined ||
alternatives.length === 0 ||
depth > REDUCED_ITEM_MAX_DEPTH
)
return {};
const types: string[] = [];
for (const { schema, scope } of alternatives) {
const detected = detectedTypes(schema, references, scope);
if (detected === undefined || detected.length === 0) return {};
types.push(...detected);
}
const unique = [...new Set(types)];
if (!unique.includes("array"))
return { type: unique.length === 1 ? unique[0] : unique };
if (unique.length > 1) return {};
const nested: ScopedSchema[] = [];
for (const { schema, scope } of alternatives) {
const items = arrayItemAlternatives(schema, references, scope);
if (items === undefined) return { type: "array", items: {} };
nested.push(...items);
}
return {
type: "array",
items: reducedItemSchema(nested, references, depth + 1),
};
};
/** Give homogeneous array-item unions the type required by strict gateways. */
const typeArrayItemUnions = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const references = new JsonSchemaReferences(schema);
const visit = (node: unknown): unknown => {
const rebuilt = rebuildSchemaPositions(node, visit);
if (!isSchemaObject(rebuilt) || rebuilt.type !== "array") return rebuilt;
const items = rebuilt.items;
if (
!isSchemaObject(items) ||
items.type !== undefined ||
(!Array.isArray(items.oneOf) && !Array.isArray(items.anyOf))
)
return rebuilt;
const types = detectedTypes(items, references, references.rootScope);
return types?.length === 1
? { ...rebuilt, items: { ...items, type: types[0] } }
: rebuilt;
};
return visit(schema) as Record<string, unknown>;
};
/**
* Strict gateways reject some nested combinators, primitive enums without a
* type, and array items whose union has no explicit type. Homogeneous item
* unions gain their shared type; remaining incompatible shapes use the shallow
* projection while canonical runtime validation stays intact.
*/
const hasGatewayIncompatibleShape = (schema: unknown): boolean => {
if (Array.isArray(schema)) return schema.some(hasGatewayIncompatibleShape);
if (!isSchemaObject(schema)) return false;
if (Array.isArray(schema.anyOf) && schema.anyOf.length === 0) return true;
if (
schema.type === "array" &&
isSchemaObject(schema.items) &&
schema.items.type === undefined &&
(Array.isArray(schema.items.oneOf) || Array.isArray(schema.items.anyOf))
)
return true;
if (Array.isArray(schema.required) && isSchemaObject(schema.properties)) {
for (const name of schema.required) {
const property = schema.properties[name];
if (isSchemaObject(property) && containsGatewayCombinator(property))
return true;
}
}
if (
Array.isArray(schema.enum) &&
schema.enum.length > 0 &&
schema.type === undefined
) {
const types = enumValueTypes(schema.enum);
if (
types.length === 1 &&
types.every((type) =>
["string", "number", "boolean", "null"].includes(type),
)
)
return true;
}
return schemaChildrenAndMaps(schema).some(hasGatewayIncompatibleShape);
};
const containsGatewayCombinator = (schema: unknown): boolean => {
if (Array.isArray(schema)) return schema.some(containsGatewayCombinator);
if (!isSchemaObject(schema)) return false;
if (schema.oneOf !== undefined || schema.allOf !== undefined) return true;
return schemaChildrenAndMaps(schema).some(containsGatewayCombinator);
};
/** Traverse only schema-valued positions; property names remain opaque data. */
const schemaChildrenAndMaps = (
schema: Readonly<Record<string, unknown>>,
): readonly unknown[] =>
Object.entries(schema).flatMap(([key, value]) => {
if (schemaMaps.has(key) && isSchemaObject(value))
return Object.values(value);
if (schemaArrays.has(key) && Array.isArray(value)) return value;
if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value];
return [];
});
/**
* Present a reduced property's reference-resolved types. Reduced inputs omit
* nested validation, but clients still require an item schema for an array
* declaration, and Gemini accepts `items` only beside the single `array` type,
* so a property that may be an array or another type lists typed branches.
*/
const reducedPropertyTypes = (
value: unknown,
references: JsonSchemaReferences,
): Record<string, unknown> => {
const types = detectedTypes(value, references, references.rootScope);
if (types === undefined || types.length === 0) return {};
if (!types.includes("array"))
return { type: types.length === 1 ? types[0] : types };
const array = {
type: "array",
items: reducedItemSchema(
arrayItemAlternatives(value, references, references.rootScope),
references,
),
};
return types.length === 1
? array
: {
anyOf: types.map((type) => (type === "array" ? array : { type })),
};
};
const REDUCED_ANNOTATION =
const INPUT_REDUCED_ANNOTATION =
"Advertised in reduced form by the compact input schema profile; the server still validates complete canonical inputs.";
/**
* Present only the root property shape with per-property guidance. Used for
* schemas whose validation structure alone exceeds the compact budget; the
* canonical schema still rejects malformed calls server-side.
*/
const reduceToShallowProperties = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const properties = isSchemaObject(schema.properties) ? schema.properties : {};
const description = [
typeof schema.description === "string" ? schema.description : undefined,
...Object.entries(
isSchemaObject(schema.dependentRequired)
? schema.dependentRequired
: isSchemaObject(schema.dependencies)
? schema.dependencies
: {},
).flatMap(([property, partners]) =>
Array.isArray(partners) &&
partners.every((partner) => typeof partner === "string")
? [
`When ${property} is provided, also provide ${partners.join(" and ")}.`,
]
: [],
),
REDUCED_ANNOTATION,
]
.filter((text) => text !== undefined)
.join(" ");
const references = new JsonSchemaReferences(schema);
const shallow = Object.fromEntries(
Object.entries(properties).map(([name, value]) => {
const description =
isSchemaObject(value) && typeof value.description === "string"
? { description: value.description }
: {};
return [
name,
{ ...reducedPropertyTypes(value, references), ...description },
];
}),
);
return {
...(typeof schema.$schema === "string" ? { $schema: schema.$schema } : {}),
type: "object",
description,
properties: shallow,
...(Array.isArray(schema.required)
? { required: [...schema.required] }
: {}),
...(typeof schema.minProperties === "number"
? { minProperties: schema.minProperties }
: {}),
...(schema.additionalProperties === false
? { additionalProperties: false }
: {}),
};
};
/**
* Render an advertised input schema within a per-schema byte budget for
* providers that validate the literal serialized schema size. Validation
@@ -631,19 +49,22 @@ export const compactAdvertisedInputSchema = (
!hasGatewayIncompatibleShape(inline)
)
return { schema: inline, presentation: "inline" };
const reduced = reduceToShallowProperties(schema);
const reduced = reduceToShallowProperties(schema, INPUT_REDUCED_ANNOTATION);
if (serializedBytes(reduced) <= budgetBytes)
return { schema: reduced, presentation: "reduced" };
// A future contract with an extreme root-property count still advertises
// an honest, budget-fitting shape instead of an oversized one.
return {
schema: reduceToShallowProperties({
type: "object",
description:
(typeof schema.description === "string"
? `${schema.description} `
: "") + REDUCED_ANNOTATION,
}),
schema: reduceToShallowProperties(
{
type: "object",
description:
(typeof schema.description === "string"
? `${schema.description} `
: "") + INPUT_REDUCED_ANNOTATION,
},
INPUT_REDUCED_ANNOTATION,
),
presentation: "reduced",
};
};
+597
View File
@@ -0,0 +1,597 @@
import {
JsonSchemaReferences,
type JsonSchemaScope,
} from "./jsonSchemaReferences.js";
import {
schemaArrays,
schemaChildren,
schemaMaps,
schemaLiteralValues,
} from "./jsonSchemaKeywords.js";
// Shared compact JSON Schema projection primitives; canonical validation stays separate.
/** Annotation keys that carry guidance but no validation semantics. */
const annotationKeys = new Set(["description", "examples", "default", "title"]);
const definitionKeys = new Set(["$defs", "definitions"]);
// An empty schema accepts every JSON value. Kimi otherwise infers string.
const jsonValueTypes = [
"null",
"boolean",
"object",
"array",
"number",
"string",
];
// An empty item schema accepts every JSON value, including arbitrarily nested
// arrays, while satisfying clients that require `items` for array types.
// Gemini rejects `items` beside a type list; the compact catalog test fails
// if a tool would advertise this presentation.
export const jsonValueSchema = () => ({ type: [...jsonValueTypes], items: {} });
const enumValueTypes = (values: readonly unknown[]): readonly string[] => [
...new Set(
values.map((value) => {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
if (typeof value === "object") return "object";
return typeof value;
}),
),
];
export const isSchemaObject = (
value: unknown,
): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const serializedBytes = (value: unknown): number =>
Buffer.byteLength(JSON.stringify(value), "utf8");
export const schemaDepth = (value: unknown, depth = 0): number => {
if (Array.isArray(value))
return Math.max(
depth,
...value.map((child) => schemaDepth(child, depth + 1)),
);
if (!isSchemaObject(value)) return depth;
return Math.max(
depth,
...Object.values(value).map((child) => schemaDepth(child, depth + 1)),
);
};
/** Strict clients commonly reject advertised schemas deeper than ten nodes. */
export const COMPACT_SCHEMA_MAX_DEPTH = 10;
/** Rewrite every schema-valued position; literal data stays opaque. */
const rebuildSchemaPositions = (
node: unknown,
rebuild: (child: unknown) => unknown,
): unknown => {
if (Array.isArray(node)) return node.map((child) => rebuild(child));
if (!isSchemaObject(node)) return node;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node)) {
if (schemaLiteralValues.has(key)) {
out[key] = value;
continue;
}
if (schemaMaps.has(key) && isSchemaObject(value)) {
out[key] = Object.fromEntries(
Object.entries(value).map(([name, child]) => [name, rebuild(child)]),
);
continue;
}
if (schemaArrays.has(key) && Array.isArray(value)) {
out[key] = value.map((child) => rebuild(child));
continue;
}
if (schemaChildren.has(key)) {
out[key] = Array.isArray(value)
? value.map((child) => rebuild(child))
: rebuild(value);
continue;
}
out[key] = value;
}
return out;
};
/**
* Drop annotation prose from every subschema while preserving all validation
* keywords. The root keeps its own description because the root-union
* projection encodes accepted input groups there; the root's echoed literal
* examples leave with the rest of the annotation prose.
*/
export const stripAnnotations = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const rebuilt = rebuildSchemaPositions(
schema,
stripChildAnnotations,
) as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rebuilt)) {
if (annotationKeys.has(key) && key !== "description") continue;
out[key] = value;
}
return out;
};
const stripChildAnnotations = (child: unknown): unknown => {
const rebuilt = rebuildSchemaPositions(child, stripChildAnnotations);
if (!isSchemaObject(rebuilt)) return rebuilt;
const kept: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rebuilt))
if (!annotationKeys.has(key)) kept[key] = value;
return kept;
};
/** Stop serialization at the budget before allocating an expanded schema. */
export const inlineWithinBudget = (
root: Record<string, unknown>,
budgetBytes: number,
): Record<string, unknown> | undefined => {
const references = new JsonSchemaReferences(root);
const identity = Object.fromEntries(
["$id", "$anchor", "$schema"].flatMap((key) =>
Object.hasOwn(root, key) ? [[key, root[key]]] : [],
),
);
const identityBytes =
Object.keys(identity).length === 0 ? 0 : serializedBytes(identity) - 2;
if (identityBytes > budgetBytes) return undefined;
let resolved = true;
function* serialize(
value: unknown,
position: "schema" | "map" | "data",
scope: JsonSchemaScope | undefined,
active: ReadonlyMap<JsonSchemaScope, ReadonlySet<string>>,
): Generator<string> {
if (Array.isArray(value)) {
yield "[";
for (const [index, child] of value.entries()) {
if (index > 0) yield ",";
yield* serialize(child, position, scope, active);
}
yield "]";
} else if (isSchemaObject(value)) {
const currentScope =
position === "schema" ? references.enter(value, scope) : scope;
if (
position === "schema" &&
(currentScope === undefined ||
currentScope.dialect !== references.rootScope.dialect ||
[
"$dynamicRef",
"$recursiveRef",
"$dynamicAnchor",
"$recursiveAnchor",
].some((key) => Object.hasOwn(value, key)))
) {
resolved = false;
return;
}
if (position === "schema" && Object.keys(value).length === 0) {
yield JSON.stringify(jsonValueSchema());
return;
}
if (position === "schema" && typeof value.$ref === "string") {
const target = references.resolve(currentScope, value.$ref);
if (
target.kind !== "target" ||
currentScope === undefined ||
active.get(currentScope)?.has(value.$ref)
) {
resolved = false;
return;
}
const {
$ref,
$id: _id,
$anchor: _anchor,
$schema: _schema,
...siblings
} = value;
const nextActive = new Map(active);
nextActive.set(
currentScope,
new Set([...(active.get(currentScope) ?? []), $ref]),
);
// Draft 7 ignores siblings, while clients such as Ajv also apply them.
// Do not label either interpretation an equivalent inline advertisement.
if (
currentScope.dialect === "draft-07" &&
Object.keys(siblings).length > 0
) {
resolved = false;
return;
}
const expanded =
Object.keys(siblings).length === 0
? target.value
: { allOf: [target.value, siblings] };
yield* serialize(expanded, "schema", currentScope, nextActive);
return;
}
yield "{";
let first = true;
for (const [key, child] of Object.entries(value)) {
if (
position === "schema" &&
(definitionKeys.has(key) ||
key === "$id" ||
key === "$anchor" ||
key === "$schema")
)
continue;
if (!first) yield ",";
first = false;
yield JSON.stringify(key) + ":";
// `not: {}` forbids a value; no client infers a type for a value it
// must omit, and the any-value list would place `items` beside a
// non-array type, which Gemini function declarations reject.
if (
position === "schema" &&
key === "not" &&
isSchemaObject(child) &&
Object.keys(child).length === 0
) {
yield "{}";
continue;
}
const childPosition =
position === "map"
? "schema"
: position === "schema" && schemaMaps.has(key)
? "map"
: position === "schema" &&
(schemaArrays.has(key) || schemaChildren.has(key))
? "schema"
: "data";
yield* serialize(child, childPosition, currentScope, active);
}
yield "}";
} else {
yield JSON.stringify(value);
}
}
const chunks: string[] = [];
let bytes = identityBytes;
for (const chunk of serialize(
root,
"schema",
references.rootScope,
new Map(),
)) {
bytes += Buffer.byteLength(chunk, "utf8");
if (bytes > budgetBytes || !resolved) return undefined;
chunks.push(chunk);
}
if (!resolved) return undefined;
const expanded: unknown = JSON.parse(chunks.join(""));
if (!isSchemaObject(expanded)) return undefined;
// Every emitted dependency is now static and reference-free. Nested resource
// identifiers/anchors can leave without duplicating identities at call sites.
const schema = { ...identity, ...expanded };
return serializedBytes(schema) <= budgetBytes ? schema : undefined;
};
/** Preserve every possible type, including referenced and nullable unions. */
const detectedTypes = (
schema: unknown,
references: JsonSchemaReferences,
inherited: JsonSchemaScope | undefined,
active: ReadonlySet<object> = new Set(),
): readonly string[] | undefined => {
if (!isSchemaObject(schema)) return undefined;
const scope = references.enter(schema, inherited);
if (scope === undefined) return undefined;
if (typeof schema.$ref === "string") {
if (active.has(schema)) return undefined;
const target = references.resolve(scope, schema.$ref);
return target.kind === "target"
? detectedTypes(
target.value,
references,
scope,
new Set([...active, schema]),
)
: undefined;
}
if (typeof schema.type === "string") return [schema.type];
if (
Array.isArray(schema.type) &&
schema.type.every((type) => typeof type === "string")
)
return schema.type;
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
const enumTypes = enumValueTypes(schema.enum);
if (enumTypes.every((type) => jsonValueTypes.includes(type)))
return enumTypes;
}
const alternatives = Array.isArray(schema.anyOf)
? schema.anyOf
: Array.isArray(schema.oneOf)
? schema.oneOf
: [];
if (alternatives.length === 0) return undefined;
const types = alternatives.map((branch) =>
detectedTypes(branch, references, scope, active),
);
if (types.some((type) => type === undefined)) return undefined;
return [...new Set(types.flatMap((type) => type ?? []))];
};
interface ScopedSchema {
readonly schema: Record<string, unknown>;
readonly scope: JsonSchemaScope;
}
/**
* Collect the item schema of every array alternative. Tuples, boolean item
* schemas and unresolved references are unknown because one item type would
* not describe every accepted element.
*/
const arrayItemAlternatives = (
schema: unknown,
references: JsonSchemaReferences,
inherited: JsonSchemaScope | undefined,
active: ReadonlySet<object> = new Set(),
): readonly ScopedSchema[] | undefined => {
if (!isSchemaObject(schema)) return undefined;
const scope = references.enter(schema, inherited);
if (scope === undefined) return undefined;
if (typeof schema.$ref === "string") {
if (active.has(schema)) return undefined;
const target = references.resolve(scope, schema.$ref);
return target.kind === "target"
? arrayItemAlternatives(
target.value,
references,
scope,
new Set([...active, schema]),
)
: undefined;
}
const types =
typeof schema.type === "string"
? [schema.type]
: Array.isArray(schema.type)
? schema.type
: undefined;
if (types !== undefined) {
if (!types.includes("array")) return [];
return isSchemaObject(schema.items) && schema.prefixItems === undefined
? [{ schema: schema.items, scope }]
: undefined;
}
const alternatives = Array.isArray(schema.anyOf)
? schema.anyOf
: Array.isArray(schema.oneOf)
? schema.oneOf
: [];
if (alternatives.length === 0) return undefined;
const nested = alternatives.map((branch) =>
arrayItemAlternatives(branch, references, scope, active),
);
return nested.some((items) => items === undefined)
? undefined
: nested.flatMap((items) => items ?? []);
};
/** Nested arrays beyond this level advertise an unconstrained item schema. */
const REDUCED_ITEM_MAX_DEPTH = 3;
/**
* Advertise the element types the canonical item schemas accept. Gemini
* function declarations permit `items` only beside the single `array` type,
* so an element that may be an array alongside other types, or whose type is
* unknown, uses the empty schema: it accepts every element and still gives
* clients that require `items` an item schema.
*/
const reducedItemSchema = (
alternatives: readonly ScopedSchema[] | undefined,
references: JsonSchemaReferences,
depth = 1,
): Record<string, unknown> => {
if (
alternatives === undefined ||
alternatives.length === 0 ||
depth > REDUCED_ITEM_MAX_DEPTH
)
return {};
const types: string[] = [];
for (const { schema, scope } of alternatives) {
const detected = detectedTypes(schema, references, scope);
if (detected === undefined || detected.length === 0) return {};
types.push(...detected);
}
const unique = [...new Set(types)];
if (!unique.includes("array"))
return { type: unique.length === 1 ? unique[0] : unique };
if (unique.length > 1) return {};
const nested: ScopedSchema[] = [];
for (const { schema, scope } of alternatives) {
const items = arrayItemAlternatives(schema, references, scope);
if (items === undefined) return { type: "array", items: {} };
nested.push(...items);
}
return {
type: "array",
items: reducedItemSchema(nested, references, depth + 1),
};
};
/** Give homogeneous array-item unions the type required by strict gateways. */
export const typeArrayItemUnions = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const references = new JsonSchemaReferences(schema);
const visit = (node: unknown): unknown => {
const rebuilt = rebuildSchemaPositions(node, visit);
if (!isSchemaObject(rebuilt) || rebuilt.type !== "array") return rebuilt;
const items = rebuilt.items;
if (
!isSchemaObject(items) ||
items.type !== undefined ||
(!Array.isArray(items.oneOf) && !Array.isArray(items.anyOf))
)
return rebuilt;
const types = detectedTypes(items, references, references.rootScope);
return types?.length === 1
? { ...rebuilt, items: { ...items, type: types[0] } }
: rebuilt;
};
return visit(schema) as Record<string, unknown>;
};
/**
* Strict gateways reject some nested combinators, primitive enums without a
* type, and array items whose union has no explicit type. Homogeneous item
* unions gain their shared type; remaining incompatible shapes use the shallow
* projection while canonical runtime validation stays intact.
*/
export const hasGatewayIncompatibleShape = (schema: unknown): boolean => {
if (Array.isArray(schema)) return schema.some(hasGatewayIncompatibleShape);
if (!isSchemaObject(schema)) return false;
if (Array.isArray(schema.anyOf) && schema.anyOf.length === 0) return true;
if (
schema.type === "array" &&
isSchemaObject(schema.items) &&
schema.items.type === undefined &&
(Array.isArray(schema.items.oneOf) || Array.isArray(schema.items.anyOf))
)
return true;
if (Array.isArray(schema.required) && isSchemaObject(schema.properties)) {
for (const name of schema.required) {
const property = schema.properties[name];
if (isSchemaObject(property) && containsGatewayCombinator(property))
return true;
}
}
if (
Array.isArray(schema.enum) &&
schema.enum.length > 0 &&
schema.type === undefined
) {
const types = enumValueTypes(schema.enum);
if (
types.length === 1 &&
types.every((type) =>
["string", "number", "boolean", "null"].includes(type),
)
)
return true;
}
return schemaChildrenAndMaps(schema).some(hasGatewayIncompatibleShape);
};
const containsGatewayCombinator = (schema: unknown): boolean => {
if (Array.isArray(schema)) return schema.some(containsGatewayCombinator);
if (!isSchemaObject(schema)) return false;
if (schema.oneOf !== undefined || schema.allOf !== undefined) return true;
return schemaChildrenAndMaps(schema).some(containsGatewayCombinator);
};
/** Traverse only schema-valued positions; property names remain opaque data. */
const schemaChildrenAndMaps = (
schema: Readonly<Record<string, unknown>>,
): readonly unknown[] =>
Object.entries(schema).flatMap(([key, value]) => {
if (schemaMaps.has(key) && isSchemaObject(value))
return Object.values(value);
if (schemaArrays.has(key) && Array.isArray(value)) return value;
if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value];
return [];
});
/**
* Present a reduced property's reference-resolved types. Reduced inputs omit
* nested validation, but clients still require an item schema for an array
* declaration, and Gemini accepts `items` only beside the single `array` type,
* so a property that may be an array or another type lists typed branches.
*/
const reducedPropertyTypes = (
value: unknown,
references: JsonSchemaReferences,
): Record<string, unknown> => {
const types = detectedTypes(value, references, references.rootScope);
if (types === undefined || types.length === 0) return {};
if (!types.includes("array"))
return { type: types.length === 1 ? types[0] : types };
const array = {
type: "array",
items: reducedItemSchema(
arrayItemAlternatives(value, references, references.rootScope),
references,
),
};
return types.length === 1
? array
: {
anyOf: types.map((type) => (type === "array" ? array : { type })),
};
};
/**
* Present only the root property shape with per-property guidance. Used for
* schemas whose validation structure alone exceeds the compact budget; the
* canonical schema still rejects malformed calls server-side.
*/
export const reduceToShallowProperties = (
schema: Record<string, unknown>,
reducedAnnotation: string,
): Record<string, unknown> => {
const properties = isSchemaObject(schema.properties) ? schema.properties : {};
const description = [
typeof schema.description === "string" ? schema.description : undefined,
...Object.entries(
isSchemaObject(schema.dependentRequired)
? schema.dependentRequired
: isSchemaObject(schema.dependencies)
? schema.dependencies
: {},
).flatMap(([property, partners]) =>
Array.isArray(partners) &&
partners.every((partner) => typeof partner === "string")
? [
`When ${property} is provided, also provide ${partners.join(" and ")}.`,
]
: [],
),
reducedAnnotation,
]
.filter((text) => text !== undefined)
.join(" ");
const references = new JsonSchemaReferences(schema);
const shallow = Object.fromEntries(
Object.entries(properties).map(([name, value]) => {
const description =
isSchemaObject(value) && typeof value.description === "string"
? { description: value.description }
: {};
return [
name,
{ ...reducedPropertyTypes(value, references), ...description },
];
}),
);
return {
...(typeof schema.$schema === "string" ? { $schema: schema.$schema } : {}),
type: "object",
description,
properties: shallow,
...(Array.isArray(schema.required)
? { required: [...schema.required] }
: {}),
...(typeof schema.minProperties === "number"
? { minProperties: schema.minProperties }
: {}),
...(schema.additionalProperties === false
? { additionalProperties: false }
: {}),
};
};
+171
View File
@@ -0,0 +1,171 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { compactAdvertisedOutputSchema } from "./compactOutputSchema.js";
const serializedBytes = (value: unknown): number =>
Buffer.byteLength(JSON.stringify(value), "utf8");
const annotatedSchema = {
type: "object",
description: "Root result guidance stays.",
properties: {
value: {
type: "string",
description: "Field guidance leaves.",
examples: ["seed"],
default: "seed",
title: "Value",
},
nested: {
type: "object",
description: "Nested guidance leaves.",
properties: {
count: { type: "integer", description: "Leaves.", minimum: 1 },
},
required: ["count"],
additionalProperties: false,
},
},
required: ["value", "nested"],
additionalProperties: false,
} as const;
const referencedSchema = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "urn:rea:tool-output:sha256:fixture",
type: "object",
properties: {
entries: { type: "array", items: { $ref: "#/$defs/entry" } },
},
required: ["entries"],
$defs: {
entry: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
} as const;
const oversizedSchema = (): Record<string, unknown> => ({
type: "object",
description: "A complete result envelope.",
properties: {
envelope: { type: "string" },
result: {
type: "object",
properties: Object.fromEntries(
Array.from({ length: 200 }, (_, index) => [
`field_${index}`,
{ type: "string", description: `Field ${index}` },
]),
),
required: Array.from({ length: 200 }, (_, index) => `field_${index}`),
additionalProperties: false,
},
},
required: ["envelope", "result"],
additionalProperties: false,
});
describe("compact advertised output schema", () => {
it("drops nested annotation prose while keeping the root guidance and validation", () => {
const { schema, presentation } = compactAdvertisedOutputSchema(
structuredClone(annotatedSchema),
4 * 1024,
);
expect(presentation).toBe("inline");
expect(schema.description).toBe("Root result guidance stays.");
const value = (schema.properties as Record<string, unknown>)
.value as Record<string, unknown>;
expect(value).toEqual({ type: "string" });
const nested = (schema.properties as Record<string, unknown>)
.nested as Record<string, unknown>;
expect(nested.description).toBeUndefined();
expect(nested.required).toEqual(["count"]);
expect(schema.required).toEqual(["value", "nested"]);
expect(schema.additionalProperties).toBe(false);
});
it("inlines local definitions and drops the content-bound identity", () => {
const { schema, presentation } = compactAdvertisedOutputSchema(
structuredClone(referencedSchema),
4 * 1024,
);
expect(presentation).toBe("inline");
expect(schema.$defs).toBeUndefined();
expect(schema.$id).toBeUndefined();
expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
const entries = (schema.properties as Record<string, unknown>)
.entries as Record<string, unknown>;
expect(entries.items).toMatchObject({ type: "object" });
});
it("reduces an oversized contract to a widening root shape within budget", () => {
const budgetBytes = 4 * 1024;
const { schema, presentation } = compactAdvertisedOutputSchema(
oversizedSchema(),
budgetBytes,
);
expect(presentation).toBe("reduced");
expect(serializedBytes(schema)).toBeLessThanOrEqual(budgetBytes);
expect(schema.type).toBe("object");
expect(schema.required).toEqual(["envelope", "result"]);
expect(schema.additionalProperties).toBe(false);
expect(schema.description).toContain("compact output schema profile");
const result = (schema.properties as Record<string, unknown>)
.result as Record<string, unknown>;
expect(result.type).toBe("object");
expect(result.properties).toBeUndefined();
});
it("accepts a valid canonical result after reduction", () => {
const budgetBytes = 4 * 1024;
const { schema } = compactAdvertisedOutputSchema(
oversizedSchema(),
budgetBytes,
);
const validate = new Ajv2020({ strict: false }).compile(schema);
const result = {
envelope: "ok",
result: Object.fromEntries(
Array.from({ length: 200 }, (_, index) => [
`field_${index}`,
`value ${index}`,
]),
),
};
expect(validate(result)).toBe(true);
// The advertisement is wider than the canonical contract: dropping nested
// detail must never reject a valid result, but it can accept an invalid one.
expect(validate({ envelope: "ok", result: { unknown: 1 } })).toBe(true);
});
it("keeps a non-object root accepting every JSON value when it cannot fit", () => {
const arraySchema = {
type: "array",
items: Object.fromEntries(
Array.from({ length: 200 }, (_, index) => [
`field_${index}`,
{ type: "string", description: `Field ${index}` },
]),
),
};
const { schema, presentation } = compactAdvertisedOutputSchema(
arraySchema,
128,
);
expect(presentation).toBe("reduced");
expect(serializedBytes(schema)).toBeLessThanOrEqual(128);
const validate = new Ajv2020({ strict: false }).compile(schema);
expect(validate(["anything"])).toBe(true);
expect(validate({ also: "anything" })).toBe(true);
});
it("is deterministic across equivalent inputs", () => {
expect(compactAdvertisedOutputSchema(oversizedSchema(), 4 * 1024)).toEqual(
compactAdvertisedOutputSchema(oversizedSchema(), 4 * 1024),
);
});
});
+95
View File
@@ -0,0 +1,95 @@
import {
COMPACT_SCHEMA_MAX_DEPTH,
inlineWithinBudget,
isSchemaObject,
jsonValueSchema,
reduceToShallowProperties,
schemaDepth,
serializedBytes,
stripAnnotations,
} from "./compactJsonSchema.js";
// Output schemas dominate a complete `tools/list` frame. The compact profile
// bounds each advertised output contract so frame-capped hosts can finish the
// handshake, while canonical result validation and `structuredContent` stay
// unchanged. Every projection is a widening: a valid canonical result always
// satisfies the advertised schema.
export type AdvertisedOutputSchemaPresentation = "inline" | "reduced";
/** Budget selected by the caller for compact MCP output-schema presentation. */
export interface CompactOutputSchemaPresentation {
readonly budgetBytes: number;
}
export interface CompactedOutputSchema {
readonly schema: Record<string, unknown>;
readonly presentation: AdvertisedOutputSchemaPresentation;
}
const OUTPUT_REDUCED_ANNOTATION =
"Advertised in reduced form by the compact output schema profile; the server still validates complete canonical results.";
/**
* Drop the canonical contract's content-bound `$id` and anchors. The compact
* bytes differ from the canonical schema the identity was derived from, so
* retaining that identity would misstate what the advertised document is.
* References are already inlined, so no identity is needed to resolve them.
*/
const withoutContentBoundIdentity = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const { $id: _id, $anchor: _anchor, ...advertised } = schema;
return advertised;
};
/**
* Render an advertised output schema within a per-schema byte budget. The
* complete validation structure is kept when it fits; otherwise the root
* property shape and first-level types remain and nested detail is elided.
* The registered canonical schema still validates every delivered result.
*/
export const compactAdvertisedOutputSchema = (
schema: Record<string, unknown>,
budgetBytes: number,
): CompactedOutputSchema => {
const inline = inlineWithinBudget(stripAnnotations(schema), budgetBytes);
if (inline !== undefined && schemaDepth(inline) <= COMPACT_SCHEMA_MAX_DEPTH)
return {
schema: withoutContentBoundIdentity(inline),
presentation: "inline",
};
// Every REA output contract is an object at the root. A non-object root still
// advertises an accepting shape rather than a projection that could reject
// valid canonical output.
const reduced =
schema.type === "object" || isSchemaObject(schema.properties)
? reduceToShallowProperties(schema, OUTPUT_REDUCED_ANNOTATION)
: jsonValueSchema();
if (serializedBytes(reduced) <= budgetBytes)
return {
schema: withoutContentBoundIdentity(reduced),
presentation: "reduced",
};
// A root description can itself be large; keep the advertisement inside the
// budget instead of carrying it into a shape with no nested detail left.
const minimal = reduceToShallowProperties(
{ type: "object" },
OUTPUT_REDUCED_ANNOTATION,
);
return {
schema:
serializedBytes(minimal) <= budgetBytes ? minimal : jsonValueSchema(),
presentation: "reduced",
};
};
/** Return the JSON schema clients receive for the selected output presentation. */
export const presentAdvertisedOutputSchema = (
schema: Record<string, unknown>,
compactPresentation: CompactOutputSchemaPresentation | undefined,
): Record<string, unknown> =>
compactPresentation === undefined
? schema
: compactAdvertisedOutputSchema(schema, compactPresentation.budgetBytes)
.schema;
+1 -1
View File
@@ -1,7 +1,7 @@
import type { CatalogDigestProfiles } from "./catalogIdentity.js";
/**
* Digests of the four built-in input/output schema profiles, recorded
* Digests of the six built-in input/output schema profiles, recorded
* from the compiled contracts by `scripts/record-catalog-digests.mjs` after
* compilation. Source runs leave this undefined and compute them on first use.
*/
+2 -1
View File
@@ -70,7 +70,8 @@ export class DeferredEvidenceMcpServer extends EvidenceMcpServerCore {
undefined,
delivery,
undefined,
false,
// `main.ts` only defers the exact full advertisement catalog.
"full",
catalog.identity,
);
for (const tool of catalog.tools) {
+5 -7
View File
@@ -7,6 +7,7 @@ import {
FULL_CATALOG_IDENTITY,
createCatalogIdentity,
} from "../catalogIdentity.js";
import type { McpOutputSchemaProfile } from "../config/mcpOutputSchemaProfile.js";
import type { ToolResultDelivery } from "./ToolResultDelivery.js";
import {
EvidenceMcpServerCore,
@@ -23,7 +24,7 @@ export class EvidenceMcpServer extends EvidenceMcpServerCore {
recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
delivery: ToolResultDelivery,
compactPresentation: CompactInputSchemaPresentation | undefined = undefined,
omitOutputSchemas = false,
outputSchemaProfile: McpOutputSchemaProfile = "full",
identity?: ReturnType<typeof createCatalogIdentity>,
) {
super(
@@ -32,14 +33,11 @@ export class EvidenceMcpServer extends EvidenceMcpServerCore {
recordEvidence,
delivery,
compactPresentation,
omitOutputSchemas,
outputSchemaProfile,
identity ??
(compactPresentation === undefined && !omitOutputSchemas
(compactPresentation === undefined && outputSchemaProfile === "full"
? FULL_CATALOG_IDENTITY
: createCatalogIdentity(
compactPresentation,
omitOutputSchemas ? "off" : "full",
)),
: createCatalogIdentity(compactPresentation, outputSchemaProfile)),
);
}
}
+52 -14
View File
@@ -30,6 +30,11 @@ import {
presentAdvertisedInputSchema,
type CompactInputSchemaPresentation,
} from "../contracts/compactInputSchema.js";
import { presentAdvertisedOutputSchema } from "../contracts/compactOutputSchema.js";
import {
compactPresentationForMcpOutputSchemaProfile,
type McpOutputSchemaProfile,
} from "../config/mcpOutputSchemaProfile.js";
import type { createCatalogIdentity } from "../catalogIdentity.js";
import { transformAdvertisedInputJsonSchema } from "../contracts/toolSchemaMetadata.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
@@ -110,26 +115,48 @@ const replaceToolResultPayload = (
};
};
/** Remove only client-facing output schemas; SDK registrations stay canonical. */
const omitOutputSchemasFromToolsList = (
/**
* Rewrite only client-facing output schemas; SDK registrations stay canonical.
* `compact` bounds each advertised schema, while `off` omits it entirely.
* The SDK memoises each tool's converted schema, so the caller passes a cache
* keyed by that object; repeated listings reuse the same projection.
*/
const presentOutputSchemasForToolsList = (
message: JSONRPCMessage,
requestId: RequestId,
profile: McpOutputSchemaProfile,
compactedSchemas: WeakMap<Record<string, unknown>, Record<string, unknown>>,
): JSONRPCMessage => {
if (
profile === "full" ||
!isJSONRPCResultResponse(message) ||
message.id !== requestId ||
!isRecord(message.result) ||
!Array.isArray(message.result.tools)
)
return message;
const compactPresentation =
compactPresentationForMcpOutputSchemaProfile(profile);
return {
...message,
result: {
...message.result,
tools: message.result.tools.map((tool: unknown) => {
if (!isRecord(tool)) return tool;
const { outputSchema: _outputSchema, ...advertisedTool } = tool;
return advertisedTool;
if (compactPresentation === undefined) {
const { outputSchema: _outputSchema, ...advertisedTool } = tool;
return advertisedTool;
}
if (!isRecord(tool.outputSchema)) return tool;
let outputSchema = compactedSchemas.get(tool.outputSchema);
if (outputSchema === undefined) {
outputSchema = presentAdvertisedOutputSchema(
tool.outputSchema,
compactPresentation,
);
compactedSchemas.set(tool.outputSchema, outputSchema);
}
return { ...tool, outputSchema };
}),
},
} as JSONRPCMessage;
@@ -214,7 +241,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer {
private recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
public delivery: ToolResultDelivery,
compactPresentation: CompactInputSchemaPresentation | undefined = undefined,
private readonly omitOutputSchemas = false,
private readonly outputSchemaProfile: McpOutputSchemaProfile = "full",
identity?: ReturnType<typeof createCatalogIdentity>,
) {
super(info, options);
@@ -226,9 +253,9 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer {
}
/**
* Compact advertised input schemas and optionally omit output-schema
* advertisements. Canonical parsers and contracts remain registered; the
* output-schema-off path validates successful structured results locally.
* Compact advertised input schemas and bound or omit output-schema
* advertisements. Canonical parsers and contracts remain registered; both
* output profiles validate successful structured results locally.
* The signatures mirror the SDK's registrations so call sites keep their
* inferred handler argument types.
*/
@@ -286,7 +313,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer {
transport,
() => this.recordEvidence,
() => this.delivery,
this.omitOutputSchemas,
this.outputSchemaProfile,
),
);
}
@@ -309,13 +336,19 @@ class ErrorEvidenceTransport implements Transport {
return this.currentDelivery();
}
/** Bounded projections already computed for this server's budget. */
private readonly compactedOutputSchemas = new WeakMap<
Record<string, unknown>,
Record<string, unknown>
>();
constructor(
private readonly transport: Transport,
private readonly currentEvidenceWriter: () =>
| EvidenceWriter["recordEvidence"]
| undefined,
private readonly currentDelivery: () => ToolResultDelivery,
private readonly omitOutputSchemas: boolean,
private readonly outputSchemaProfile: McpOutputSchemaProfile,
) {
if (transport.hasPerRequestStream !== undefined)
this.hasPerRequestStream = transport.hasPerRequestStream;
@@ -429,14 +462,19 @@ class ErrorEvidenceTransport implements Transport {
? message.id
: undefined;
const tool = id === undefined ? undefined : this.pendingTools.get(id);
const omitToolSchemas =
this.omitOutputSchemas &&
const rewritesOutputSchemas =
this.outputSchemaProfile !== "full" &&
id !== undefined &&
this.pendingToolLists.has(id);
try {
const recovered = this.recoverProtocolError(message, tool);
const advertised = omitToolSchemas
? omitOutputSchemasFromToolsList(recovered, id)
const advertised = rewritesOutputSchemas
? presentOutputSchemasForToolsList(
recovered,
id,
this.outputSchemaProfile,
this.compactedOutputSchemas,
)
: recovered;
if (
isJSONRPCResultResponse(advertised) &&
+17 -15
View File
@@ -101,7 +101,10 @@ import { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import { createToolResultDelivery } from "./toolResult.js";
import type { ToolResultDelivery } from "./ToolResultDelivery.js";
import { parseMcpResponseBudget } from "../config/mcpResponseBudget.js";
import { parseMcpOutputSchemaProfile } from "../config/mcpOutputSchemaProfile.js";
import {
parseMcpOutputSchemaProfile,
type McpOutputSchemaProfile,
} from "../config/mcpOutputSchemaProfile.js";
import {
compactPresentationForMcpInputSchemaProfile,
parseMcpInputSchemaProfile,
@@ -408,7 +411,7 @@ const createMcpServer = (
session: BinarySessionPort | undefined,
delivery: ToolResultDelivery,
compactPresentation: CompactInputSchemaPresentation | undefined,
omitOutputSchemas: boolean,
outputSchemaProfile: McpOutputSchemaProfile,
): EvidenceMcpServer =>
new EvidenceMcpServer(
{
@@ -428,11 +431,8 @@ const createMcpServer = (
: (evidence) => session.recordEvidence(evidence),
delivery,
compactPresentation,
omitOutputSchemas,
createCatalogIdentity(
compactPresentation,
omitOutputSchemas ? "off" : "full",
),
outputSchemaProfile,
createCatalogIdentity(compactPresentation, outputSchemaProfile),
);
/**
@@ -449,7 +449,9 @@ export const createServer = (
const environment = snapshotEnvironment(options.environment ?? process.env);
const analysisSettings =
options.analysisSettings ?? new AnalysisSettings(environment);
const omitOutputSchemas = outputSchemasOmitted(environment);
const compactInputPresentation =
selectCompactInputSchemaPresentation(environment);
const selectedOutputSchemaProfile = outputSchemaProfile(environment);
const providerEnvironment = captureProviderEnvironment(
options.providerEnvironment,
environment,
@@ -516,8 +518,8 @@ export const createServer = (
createMcpServer(
session,
delivery,
selectCompactInputSchemaPresentation(environment),
omitOutputSchemas,
compactInputPresentation,
selectedOutputSchemaProfile,
);
server.analysisSettings = analysisSettings;
registerAnalysisSettingsTools(server, analysisSettings);
@@ -528,8 +530,8 @@ export const createServer = (
: (evidence) => session.recordEvidence(evidence),
delivery,
createCatalogIdentity(
selectCompactInputSchemaPresentation(environment),
omitOutputSchemas ? "off" : "full",
compactInputPresentation,
selectedOutputSchemaProfile,
),
);
}
@@ -882,14 +884,14 @@ const selectToolResultDelivery = (
return createToolResultDelivery(configured.value);
};
const outputSchemasOmitted = (
const outputSchemaProfile = (
environment: Readonly<NodeJS.ProcessEnv>,
): boolean => {
): McpOutputSchemaProfile => {
const configured = parseMcpOutputSchemaProfile(
environment.REA_MCP_OUTPUT_SCHEMA_PROFILE,
);
if (!configured.ok) throw configured.error;
return configured.value === "off";
return configured.value ?? "full";
};
const selectCompactInputSchemaPresentation = (
+60 -50
View File
@@ -63,6 +63,64 @@ const assertCliCatalogIdentity = async (
}
};
const catalogProfiles = [
{
name: "default compact input",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: undefined,
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
},
identity: createCatalogIdentity(
compactPresentationForMcpInputSchemaProfile(undefined),
),
},
{
name: "full",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
},
identity: FULL_CATALOG_IDENTITY,
},
{
name: "compact",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
},
identity: createCatalogIdentity(
compactPresentationForMcpInputSchemaProfile("compact"),
),
},
{
name: "output schemas off",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
},
identity: createCatalogIdentity(undefined, "off"),
},
{
name: "output schemas compact",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact",
},
identity: createCatalogIdentity(undefined, "compact"),
},
{
name: "compact input and output schemas off",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
},
identity: createCatalogIdentity(
compactPresentationForMcpInputSchemaProfile("compact"),
"off",
),
},
];
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.
@@ -79,7 +137,7 @@ describe("production MCP doctor", () => {
assert.ok(Object.isFrozen(profiles));
for (const input of ["full", "compact"]) {
assert.ok(Object.isFrozen(profiles[input]));
for (const output of ["full", "off"]) {
for (const output of ["full", "compact", "off"]) {
const identity = createCatalogIdentity(input === "compact" ? { budgetBytes } : undefined, output);
assert.equal(identity.digests, profiles[input][output]);
assert.ok(Object.isFrozen(identity.digests));
@@ -124,55 +182,7 @@ describe("production MCP doctor", () => {
});
});
it.each([
{
name: "default compact input",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: undefined,
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
},
identity: createCatalogIdentity(
compactPresentationForMcpInputSchemaProfile(undefined),
),
},
{
name: "full",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
},
identity: FULL_CATALOG_IDENTITY,
},
{
name: "compact",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
},
identity: createCatalogIdentity(
compactPresentationForMcpInputSchemaProfile("compact"),
),
},
{
name: "output schemas off",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
},
identity: createCatalogIdentity(undefined, "off"),
},
{
name: "compact input and output schemas off",
environment: {
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
},
identity: createCatalogIdentity(
compactPresentationForMcpInputSchemaProfile("compact"),
"off",
),
},
])(
it.each(catalogProfiles)(
"verifies the $name catalog through production stdio and both CLI inventories",
async ({ environment, identity }) => {
const result = await runProductionMcpDoctor({
+239 -9
View File
@@ -1,4 +1,5 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { z } from "zod";
import { expect, it } from "vitest";
@@ -6,24 +7,34 @@ import {
TOOL_CONTRACTS,
toolContract,
} from "../../../src/contracts/toolContracts.js";
import { COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES } from "../../../src/config/mcpOutputSchemaProfile.js";
import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES } from "../../../src/config/mcpInputSchemaProfile.js";
import type { CompactInputSchemaPresentation } from "../../../src/contracts/compactInputSchema.js";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
import type { ToolContract } from "../../../src/contracts/toolContractTypes.js";
import { FULL_CATALOG_IDENTITY } from "../../../src/catalogIdentity.js";
import type { McpOutputSchemaProfile } from "../../../src/config/mcpOutputSchemaProfile.js";
import { advertisedSchemaErrors } from "../../fixtures/mcpAdvertisedSchemaValidity.js";
const createServer = (omitOutputSchemas: boolean) =>
const createServer = (
outputSchemaProfile: McpOutputSchemaProfile,
compactInputPresentation:
| CompactInputSchemaPresentation
| undefined = undefined,
) =>
new EvidenceMcpServer(
{ name: "output-schema-fixture", version: "1" },
{ capabilities: {} },
undefined,
createToolResultDelivery(10 * 1024 * 1024),
undefined,
omitOutputSchemas,
compactInputPresentation,
outputSchemaProfile,
);
it("omits only output schema advertisements and keeps canonical MCP output validation", async () => {
const server = createServer(true);
const server = createServer("off");
expect(server.catalogIdentity.digests.combined_sha256).not.toBe(
FULL_CATALOG_IDENTITY.digests.combined_sha256,
);
@@ -86,9 +97,97 @@ it("omits only output schema advertisements and keeps canonical MCP output valid
}
});
it("keeps a full catalog below a measured one-megabyte frame when output schemas are off", async () => {
const measure = async (omitOutputSchemas: boolean): Promise<number> => {
const server = createServer(omitOutputSchemas);
it("bounds output schema advertisements and keeps canonical MCP output validation", async () => {
const server = createServer("compact");
expect(server.catalogIdentity.digests.combined_sha256).not.toBe(
FULL_CATALOG_IDENTITY.digests.combined_sha256,
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const client = new Client({ name: "compact-output-test", version: "1" });
const contract = {
...toolContract("comment"),
name: "compact_output_schema_fixture",
inputSchema: z.strictObject({}),
outputSchema: z.strictObject({
value: z.string().describe("Nested guidance leaves."),
nested: z
.object({ count: z.number().int() })
.describe("Nested guidance leaves."),
}),
} satisfies ToolContract;
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({
content: [{ type: "text" as const, text: "fixture" }],
structuredContent: { value: "complete", nested: { count: 1 } },
}),
);
const invalidContract = {
...contract,
name: "invalid_compact_output_schema_fixture",
} satisfies ToolContract;
server.registerTool(
invalidContract.name,
toolRegistrationOptions(invalidContract),
async () => ({
content: [{ type: "text" as const, text: "invalid fixture" }],
structuredContent: { value: 42, nested: { count: 1 } },
}),
);
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const tools = (await client.listTools()).tools;
expect(tools).toHaveLength(2);
const advertised = tools.find((tool) => tool.name === contract.name)!
.outputSchema as Record<string, unknown>;
expect(advertised.type).toBe("object");
expect(advertised.$id).toBeUndefined();
expect((advertised.properties as Record<string, unknown>).value).toEqual({
type: "string",
});
expect(
(advertised.properties as Record<string, unknown>).nested,
).toMatchObject({ type: "object" });
const success = await client.callTool({
name: contract.name,
arguments: {},
});
expect(success.isError).not.toBe(true);
expect(success.structuredContent).toEqual({
value: "complete",
nested: { count: 1 },
});
const invalid = await client.callTool({
name: invalidContract.name,
arguments: {},
});
expect(invalid.isError).toBe(true);
expect(invalid.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringContaining("Output validation error"),
}),
]);
} finally {
await client.close();
await server.close();
}
});
it("keeps the default compact-input catalog below a measured one-megabyte frame", async () => {
const compactInput: CompactInputSchemaPresentation = {
budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
};
const measure = async (
profile: McpOutputSchemaProfile,
inputPresentation: CompactInputSchemaPresentation | undefined,
): Promise<number> => {
const server = createServer(profile, inputPresentation);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const client = new Client({ name: "catalog-size-test", version: "1" });
@@ -104,6 +203,26 @@ it("keeps a full catalog below a measured one-megabyte frame when output schemas
await client.connect(clientTransport);
try {
const listed = await client.listTools();
if (profile === "compact") {
// Validate the advertised documents as the client receives them, not
// just their bytes: a projection that broke a reference or left an
// invalid schema would otherwise still pass the budget assertion.
expect(
advertisedSchemaErrors(
listed.tools.map((tool) => ({
name: tool.name,
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema,
})),
),
).toEqual([]);
for (const tool of listed.tools) {
expect(tool.outputSchema).toBeDefined();
expect(
Buffer.byteLength(JSON.stringify(tool.outputSchema), "utf8"),
).toBeLessThanOrEqual(COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES);
}
}
return Buffer.byteLength(JSON.stringify(listed), "utf8");
} finally {
await client.close();
@@ -111,9 +230,120 @@ it("keeps a full catalog below a measured one-megabyte frame when output schemas
}
};
const fullBytes = await measure(false);
const compactBytes = await measure(true);
const fullBytes = await measure("full", undefined);
const compactBytes = await measure("compact", compactInput);
const offBytes = await measure("off", compactInput);
const fullInputOffBytes = await measure("off", undefined);
expect(fullBytes).toBeGreaterThan(1024 * 1024);
expect(compactBytes).toBeLessThan(1024 * 1024);
expect(offBytes).toBeLessThan(1024 * 1024);
expect(fullInputOffBytes).toBeLessThan(1024 * 1024);
expect(compactBytes).toBeLessThan(fullBytes);
expect(compactBytes).toBeGreaterThan(offBytes);
});
it("rejects canonical-invalid output that the reduced advertisement accepts", async () => {
// Enough nested detail that the compact projection cannot inline the
// canonical schema within the budget, so the advertised form is the shallow
// root shape rather than the complete validation structure.
const nestedDetail = Object.fromEntries(
Array.from({ length: 150 }, (_, index) => [
`nested_${String(index).padStart(3, "0")}`,
z.string().optional(),
]),
);
const outputSchema = z.strictObject({
value: z.string(),
payload: z.object({
inner: z.string(),
count: z.number().int(),
...nestedDetail,
}),
});
const server = createServer("compact");
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const client = new Client({ name: "reduced-output-test", version: "1" });
const contract = {
...toolContract("comment"),
name: "reduced_output_schema_fixture",
inputSchema: z.strictObject({}),
outputSchema,
} satisfies ToolContract;
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({
content: [{ type: "text" as const, text: "fixture" }],
structuredContent: {
value: "complete",
payload: { inner: "text", count: 1 },
},
}),
);
// `payload.inner` is a string canonically. The shallow advertisement keeps
// only `payload: {type: "object"}`, so it accepts the malformed value.
const malformed = {
value: "complete",
payload: { inner: 42, count: 1 },
};
const invalidContract = {
...contract,
name: "invalid_reduced_output_schema_fixture",
} satisfies ToolContract;
server.registerTool(
invalidContract.name,
toolRegistrationOptions(invalidContract),
async () => ({
content: [{ type: "text" as const, text: "invalid fixture" }],
structuredContent: malformed,
}),
);
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const tools = (await client.listTools()).tools;
const advertised = tools.find((tool) => tool.name === contract.name)!
.outputSchema as Record<string, unknown>;
expect(advertised.description).toContain("reduced form");
expect((advertised.properties as Record<string, unknown>).payload).toEqual({
type: "object",
});
expect(
Buffer.byteLength(JSON.stringify(advertised), "utf8"),
).toBeLessThanOrEqual(COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES);
// The regression this guards: the advertisement accepts a value the
// canonical contract rejects, so validating results against the
// advertisement instead of the contract would deliver malformed output.
const validateAdvertised = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(advertised);
expect(validateAdvertised(malformed)).toBe(true);
const success = await client.callTool({
name: contract.name,
arguments: {},
});
expect(success.isError).not.toBe(true);
expect(success.structuredContent).toEqual({
value: "complete",
payload: { inner: "text", count: 1 },
});
const invalid = await client.callTool({
name: invalidContract.name,
arguments: {},
});
expect(invalid.isError).toBe(true);
expect(invalid.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringContaining("Output validation error"),
}),
]);
} finally {
await client.close();
await server.close();
}
});
+7 -47
View File
@@ -13,6 +13,7 @@ import {
arrayItemSchemaError,
unsupportedInputLiteralSchemaError,
} from "../../fixtures/mcpClientSchemaConstraints.js";
import { advertisedSchemaErrors } from "../../fixtures/mcpAdvertisedSchemaValidity.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
import { servedCatalog } from "../../../src/application/environment/environmentState.js";
import { servedFrom } from "../../fixtures/environmentStateServing.js";
@@ -31,47 +32,6 @@ interface ToolSchemas {
readonly annotations?: Record<string, unknown> | undefined;
}
function schemaErrors(tools: readonly ToolSchemas[]): string[] {
// Reuse Ajv's meta-schema validators, then clear each root's reference
// namespace, including nested IDs, before validating the next schema.
const ajv = new Ajv2020({
strict: false,
validateFormats: false,
addUsedSchema: false,
});
return tools.flatMap((tool) =>
["inputSchema", "outputSchema"].flatMap((kind) => {
const schema =
kind === "inputSchema" ? tool.inputSchema : tool.outputSchema;
if (schema === undefined) return [];
try {
if (!ajv.validateSchema(schema))
return [`${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`];
ajv.compile(schema);
const clientError = arrayItemSchemaError(
schema,
`${tool.name}.${kind}`,
);
if (clientError !== undefined) return [clientError];
if (kind === "inputSchema") {
const literalError = unsupportedInputLiteralSchemaError(
schema,
`${tool.name}.${kind}`,
);
if (literalError !== undefined) return [literalError];
}
return [];
} catch (cause: unknown) {
return [
`${tool.name}.${kind}: ${cause instanceof Error ? cause.message : String(cause)}`,
];
} finally {
ajv.removeSchema();
}
}),
);
}
function expectStrictInputSchemaParity(
tools: readonly ToolSchemas[],
ajv: Ajv2020,
@@ -484,7 +444,7 @@ describe("served environment catalogs", () => {
);
expect(tools.some((tool) => tool.name === "delete_books")).toBe(true);
expect(
schemaErrors(
advertisedSchemaErrors(
tools.map((tool) => ({
name: tool.name,
inputSchema: tool.inputSchema as unknown as Record<string, unknown>,
@@ -534,7 +494,7 @@ describe("MCP JSON Schema validity", () => {
expect(tools.map(({ name }) => name).sort()).toEqual(
TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(schemaErrors(tools)).toEqual([]);
expect(advertisedSchemaErrors(tools)).toEqual([]);
expect(localInputReferences(tools)).toEqual([]);
expect(recursiveReferences(tools)).toEqual([]);
expectKnownAuthorityHints(tools);
@@ -879,7 +839,7 @@ describe("MCP process input JSON Schema", () => {
});
it("ships nonrecursive input and output schemas in the generated catalog", () => {
expect(schemaErrors(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
expect(advertisedSchemaErrors(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
expect(recursiveReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
});
@@ -888,7 +848,7 @@ it("ships input schemas without local references in the generated catalog", () =
});
it("rejects unresolved output-schema references even for uncalled tools", () => {
const errors = schemaErrors([
const errors = advertisedSchemaErrors([
{
name: "dangling",
inputSchema: { type: "object" },
@@ -901,7 +861,7 @@ it("rejects unresolved output-schema references even for uncalled tools", () =>
it("validates each tool independently when output schema IDs repeat", () => {
const outputSchema = { $id: "urn:rea:test:shared-output", type: "string" };
const errors = schemaErrors([
const errors = advertisedSchemaErrors([
{ name: "first", inputSchema: { type: "object" }, outputSchema },
{
name: "second",
@@ -974,7 +934,7 @@ it.each([false, true])(
"rejects references to another tool's nested IDs (earlier compilation fails: %s)",
(invalidPrevious) => {
const nestedId = "urn:rea:test:nested-output";
const errors = schemaErrors([
const errors = advertisedSchemaErrors([
{
name: "first",
inputSchema: { type: "object" },
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES,
compactPresentationForMcpOutputSchemaProfile,
parseMcpOutputSchemaProfile,
} from "../../../src/config/mcpOutputSchemaProfile.js";
describe("MCP output schema profile configuration", () => {
it("accepts the full, compact, and off presentations", () => {
expect(parseMcpOutputSchemaProfile(undefined)).toEqual({
ok: true,
value: undefined,
});
for (const value of ["full", "compact", "off"] as const)
expect(parseMcpOutputSchemaProfile(value)).toEqual({
ok: true,
value,
});
});
it.each(["", "FULL", "compact ", "off ", "minimal", "0"])(
"rejects unknown profiles: %j",
(value) => {
const parsed = parseMcpOutputSchemaProfile(value);
if (parsed.ok) throw new Error("Expected invalid profile");
expect(parsed.error.message).toContain("REA_MCP_OUTPUT_SCHEMA_PROFILE");
},
);
it("selects a bounded presentation only for compact", () => {
expect(compactPresentationForMcpOutputSchemaProfile("compact")).toEqual({
budgetBytes: COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES,
});
expect(compactPresentationForMcpOutputSchemaProfile(undefined)).toBe(
undefined,
);
expect(compactPresentationForMcpOutputSchemaProfile("full")).toBe(
undefined,
);
expect(compactPresentationForMcpOutputSchemaProfile("off")).toBe(undefined);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import {
arrayItemSchemaError,
unsupportedInputLiteralSchemaError,
} from "./mcpClientSchemaConstraints.js";
/** Advertised schema pair as a client receives it from `tools/list`. */
export interface AdvertisedToolSchemas {
readonly name: string;
readonly inputSchema: Record<string, unknown>;
readonly outputSchema?: Record<string, unknown> | undefined;
}
/**
* Validate advertised schemas as clients receive them: Ajv's declared-dialect
* meta-schema and reference resolution, then the constraints a real client
* enforces. Compiling catches a compact projection that drops a referenced
* definition or leaves a local `$ref` without its target, which a byte-budget
* assertion alone would not.
*/
export function advertisedSchemaErrors(
tools: readonly AdvertisedToolSchemas[],
): string[] {
// Reuse Ajv's meta-schema validators, then clear each root's reference
// namespace, including nested IDs, before validating the next schema.
const ajv = new Ajv2020({
strict: false,
validateFormats: false,
addUsedSchema: false,
// This check compiles schemas to detect invalid references but never runs
// their validators. Generated-code optimization adds substantial work here
// without checking another contract; actual SDK validators keep defaults.
code: { optimize: false },
});
return tools.flatMap((tool) =>
["inputSchema", "outputSchema"].flatMap((kind) => {
const schema =
kind === "inputSchema" ? tool.inputSchema : tool.outputSchema;
if (schema === undefined) return [];
try {
if (!ajv.validateSchema(schema))
return [`${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`];
ajv.compile(schema);
const clientError = arrayItemSchemaError(
schema,
`${tool.name}.${kind}`,
);
if (clientError !== undefined) return [clientError];
if (kind === "inputSchema") {
const literalError = unsupportedInputLiteralSchemaError(
schema,
`${tool.name}.${kind}`,
);
if (literalError !== undefined) return [literalError];
}
return [];
} catch (cause: unknown) {
return [
`${tool.name}.${kind}: ${cause instanceof Error ? cause.message : String(cause)}`,
];
} finally {
ajv.removeSchema();
}
}),
);
}