perf(mcp): reuse output validators across catalog refreshes (#1433)
Advertise content- and dialect-bound identities for self-contained output schemas so the SDK can reuse unchanged Ajv validators after complete tools/list refreshes. Preserve explicit IDs and relative reference bases. Verify changed schemas, availability transitions, reconnects, numeric and selector constraints, explicit IDs, relative references, and advertised dialects through the pinned SDK boundaries. Refs #1065, #723
This commit is contained in:
parent
0b9815b6e7
commit
dc5af6d68f
|
|
@ -36,6 +36,13 @@ after SDK conversion and check advertised validation and actual calls.
|
|||
Individual model APIs can impose additional nesting limits; complete producer
|
||||
captures can exceed ten structural levels.
|
||||
|
||||
Self-contained output schemas advertise a content-bound `$id`, including their
|
||||
declared dialect. SDK validators can reuse compiled schemas across complete
|
||||
catalog refreshes and equivalent tool outputs; changing the schema changes its
|
||||
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.
|
||||
|
||||
`compare_web_captures` accepts exactly one of two input shapes:
|
||||
|
||||
- Passive: `before` and `after` each contain `inspection`, the complete
|
||||
|
|
|
|||
|
|
@ -1,11 +1,69 @@
|
|||
import { Ajv } from "ajv";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toolContract } from "./toolContracts.js";
|
||||
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js";
|
||||
|
||||
it("keeps dialect-specific validators distinct after JSON schema serialization", () => {
|
||||
const contract = {
|
||||
...toolContract("binary_session"),
|
||||
outputSchema: z.strictObject({ value: z.number().int().min(1).max(8) }),
|
||||
};
|
||||
const output = toolOutputSchemaWithMetadata(contract);
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
for (const target of ["draft-2020-12", "draft-07"] as const) {
|
||||
const projected = output["~standard"].jsonSchema.output({ target });
|
||||
const parsed = z
|
||||
.record(z.string(), z.unknown())
|
||||
.parse(JSON.parse(JSON.stringify(projected)));
|
||||
const validate = provider.getValidator(parsed);
|
||||
expect(validate({ value: 8 }).valid).toBe(true);
|
||||
expect(validate({ value: 9 }).valid).toBe(false);
|
||||
expect(validate({ value: 1.5 }).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit schema identity and relative reference resolution", () => {
|
||||
const value = z.string();
|
||||
const canonical = z.strictObject({ value });
|
||||
const contract = {
|
||||
...toolContract("binary_session"),
|
||||
outputSchema: canonical,
|
||||
};
|
||||
const projected = toolOutputSchemaWithMetadata(contract)[
|
||||
"~standard"
|
||||
].jsonSchema.output({
|
||||
target: "draft-2020-12",
|
||||
libraryOptions: {
|
||||
override: (context: {
|
||||
readonly zodSchema: unknown;
|
||||
readonly jsonSchema: Record<string, unknown>;
|
||||
}) => {
|
||||
if (context.zodSchema !== value) return;
|
||||
delete context.jsonSchema.type;
|
||||
context.jsonSchema.$ref = "child.json";
|
||||
},
|
||||
},
|
||||
});
|
||||
const ajv = new Ajv2020({ strict: false });
|
||||
ajv.addSchema({ $id: "child.json", type: "string", minLength: 3 });
|
||||
const validate = ajv.compile(projected);
|
||||
expect(validate({ value: "abc" })).toBe(true);
|
||||
expect(validate({ value: "ab" })).toBe(false);
|
||||
|
||||
const identified = toolOutputSchemaWithMetadata({
|
||||
...contract,
|
||||
outputSchema: canonical.meta({ $id: "https://example.test/result" }),
|
||||
})["~standard"].jsonSchema.output({ target: "draft-2020-12" });
|
||||
expect(identified.$id).toBe("https://example.test/result");
|
||||
const explicit = ajv.compile(identified);
|
||||
expect(explicit({ value: "abc" })).toBe(true);
|
||||
expect(explicit({ value: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"preserves refined numeric bounds and strict repeated objects through %s references",
|
||||
(target) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z } from "zod";
|
|||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
|
||||
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
||||
|
||||
const PROPERTY_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
||||
addresses: "Ordered provider-normalized procedure addresses to analyze.",
|
||||
|
|
@ -69,20 +70,55 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
|
|||
},
|
||||
);
|
||||
|
||||
/** Share repeated output definitions without changing canonical validation. */
|
||||
/** Share output definitions and content-bound validator identities on the wire. */
|
||||
export const toolOutputSchemaWithMetadata = <Contract extends ToolContract>(
|
||||
contract: Contract,
|
||||
): Contract["outputSchema"] =>
|
||||
withAdvertisedJsonSchema(
|
||||
contract.outputSchema,
|
||||
"output",
|
||||
(project) => (options) =>
|
||||
project({
|
||||
(project) => (options) => {
|
||||
const shared = project({
|
||||
...options,
|
||||
libraryOptions: { reused: "ref", ...options.libraryOptions },
|
||||
}),
|
||||
});
|
||||
// An explicit ID owns the reference base. Relative external references
|
||||
// also depend on that base, so leave those projections unchanged.
|
||||
if ("$id" in shared || hasRelativeSchemaReference(shared)) return shared;
|
||||
return {
|
||||
...shared,
|
||||
$id: `urn:rea:tool-output:sha256:${digestCanonicalValue(
|
||||
{ target: options.target, schema: shared },
|
||||
"Tool output schema",
|
||||
)}`,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const hasRelativeSchemaReference = (
|
||||
schema: Record<string, unknown>,
|
||||
): boolean => {
|
||||
const pending: unknown[] = [schema];
|
||||
const visited = new WeakSet<object>();
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === null || typeof current !== "object" || visited.has(current))
|
||||
continue;
|
||||
visited.add(current);
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
if (
|
||||
["$id", "$ref", "$dynamicRef", "$recursiveRef"].includes(key) &&
|
||||
typeof value === "string" &&
|
||||
!value.startsWith("#") &&
|
||||
!URL.canParse(value)
|
||||
)
|
||||
return true;
|
||||
if (value !== null && typeof value === "object") pending.push(value);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const withAdvertisedJsonSchema = <Schema extends z.ZodType>(
|
||||
canonical: Schema,
|
||||
io: "input" | "output",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { toolOutputSchemaWithMetadata } from "../../../src/contracts/toolSchemaMetadata.js";
|
||||
|
||||
const advertised = <
|
||||
Schema extends z.ZodType<Readonly<Record<string, unknown>>>,
|
||||
>(
|
||||
schema: Schema,
|
||||
): Schema =>
|
||||
toolOutputSchemaWithMetadata({
|
||||
...toolContract("binary_session"),
|
||||
outputSchema: schema,
|
||||
});
|
||||
|
||||
const connect = async (server: McpServer, client: Client): Promise<void> => {
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
};
|
||||
|
||||
it("validates changed schemas after catalog refreshes and availability changes", async () => {
|
||||
const client = new Client({ name: "output-reuse-test", version: "1" });
|
||||
const server = new McpServer({ name: "output-reuse-fixture", version: "1" });
|
||||
const schema = advertised(z.strictObject({ state: z.literal("ready") }));
|
||||
let state = "ready";
|
||||
const selected = server.registerTool(
|
||||
"selected",
|
||||
{ inputSchema: z.strictObject({}), outputSchema: schema },
|
||||
() => ({ content: [], structuredContent: { state } }),
|
||||
);
|
||||
server.registerTool(
|
||||
"equivalent",
|
||||
{ inputSchema: z.strictObject({}), outputSchema: schema },
|
||||
() => ({ content: [], structuredContent: { state: "ready" } }),
|
||||
);
|
||||
try {
|
||||
await connect(server, client);
|
||||
await client.listTools();
|
||||
expect(
|
||||
(await client.callTool({ name: "selected" })).structuredContent,
|
||||
).toEqual({
|
||||
state: "ready",
|
||||
});
|
||||
await client.listTools();
|
||||
await client.callTool({ name: "equivalent" });
|
||||
|
||||
selected.update({
|
||||
outputSchema: advertised(z.strictObject({ state: z.literal("updated") })),
|
||||
});
|
||||
state = "updated";
|
||||
await client.listTools();
|
||||
expect(
|
||||
(await client.callTool({ name: "selected" })).structuredContent,
|
||||
).toEqual({
|
||||
state: "updated",
|
||||
});
|
||||
selected.disable();
|
||||
const unavailable = await client.listTools();
|
||||
expect(unavailable.tools.some((tool) => tool.name === "selected")).toBe(
|
||||
false,
|
||||
);
|
||||
await expect(client.callTool({ name: "selected" })).rejects.toThrow(
|
||||
/not found|disabled/iu,
|
||||
);
|
||||
await client.callTool({ name: "equivalent" });
|
||||
selected.enable();
|
||||
await client.listTools();
|
||||
await client.callTool({ name: "selected" });
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the new server's schema after reconnecting the client", async () => {
|
||||
const client = new Client({ name: "output-reconnect-test", version: "1" });
|
||||
const servers: McpServer[] = [];
|
||||
try {
|
||||
for (const state of ["first", "second"]) {
|
||||
const server = new McpServer({ name: `fixture-${state}`, version: "1" });
|
||||
servers.push(server);
|
||||
server.registerTool(
|
||||
"selected",
|
||||
{
|
||||
inputSchema: z.strictObject({}),
|
||||
outputSchema: advertised(z.strictObject({ state: z.literal(state) })),
|
||||
},
|
||||
() => ({ content: [], structuredContent: { state } }),
|
||||
);
|
||||
await connect(server, client);
|
||||
await client.listTools();
|
||||
expect(
|
||||
(await client.callTool({ name: "selected" })).structuredContent,
|
||||
).toEqual({ state });
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
...servers.map((server) => server.close()),
|
||||
]);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue