Compare commits

...
4 changed files with 152 additions and 8 deletions

No files matched your search

+14 -1
View File
@@ -17,6 +17,7 @@ const [
{ PROMPT_CONTRACTS },
{ TOOL_CONTRACTS },
{ getEvidenceInputContract },
{ toolOutputSchemaWithMetadata },
] = await Promise.all([
read("server/createServer.js"),
read("composition/binary.js"),
@@ -25,6 +26,7 @@ const [
read("contracts/promptContracts.js"),
read("contracts/toolContracts.js"),
read("domain/evidenceInputContract.js"),
read("contracts/toolSchemaMetadata.js"),
]);
const recordCatalog = async (profile) => {
// Catalog generation is independent of the host account and user configuration.
@@ -59,7 +61,18 @@ const recordCatalog = async (profile) => {
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const tools = (await client.listTools()).tools;
const tools = (await client.listTools()).tools.map((tool) => {
const contract = TOOL_CONTRACTS.find(({ name }) => name === tool.name);
if (contract === undefined) throw new Error(`Missing tool ${tool.name}`);
return {
...tool,
// Record the pre-codec schema. The SDK owns protocol-specific root
// wrapping for both tools/list and tools/call on the deferred server.
outputSchema: toolOutputSchemaWithMetadata(contract)[
"~standard"
].jsonSchema.output({ target: "draft-2020-12" }),
};
});
const prompts = (await client.listPrompts()).prompts.map((prompt) => {
const contract = PROMPT_CONTRACTS.find(
(candidate) => candidate.name === prompt.name,
+1
View File
@@ -15,6 +15,7 @@ export interface RecordedMcpCatalog {
readonly capabilities: ServerCapabilities;
readonly instructions: string;
readonly identity: ReturnType<typeof createCatalogIdentity>;
/** Output projections precede the SDK's negotiated root-result codec. */
readonly tools: readonly Tool[];
/** Tool names derived from canonical Evidence-input declarations at build time. */
readonly evidenceReferenceAlternatives: readonly string[];
+62 -4
View File
@@ -81,16 +81,28 @@ const fixture = async (
readonly maximumInputElements?: number;
readonly evidenceReferenceAlternative?: boolean;
readonly progressive?: boolean;
readonly unionOutput?: boolean;
readonly mode?: "legacy" | "auto";
} = {},
) => {
const started = barrier();
const calls = vi.fn<(n: number, context: ServerContext) => void>();
const closed = vi.fn();
let loads = 0;
const canonicalOutput = options.unionOutput
? z.union([output, z.strictObject({ message: z.string() })])
: output;
const recorded = {
...catalog,
tools: catalog.tools.map((tool) => ({
...tool,
outputSchema: z.toJSONSchema(canonicalOutput),
})),
};
const server = new DeferredEvidenceMcpServer(
options.evidenceReferenceAlternative
? { ...catalog, evidenceReferenceAlternatives: ["fixture"] }
: catalog,
? { ...recorded, evidenceReferenceAlternatives: ["fixture"] }
: recorded,
new ToolResultDelivery(1024 * 1024),
async (target) => {
loads++;
@@ -106,7 +118,7 @@ const fixture = async (
target.materialize(() => {
target.registerTool(
"fixture",
{ inputSchema: input, outputSchema: output },
{ inputSchema: input, outputSchema: canonicalOutput },
async (args, context) => {
calls(args.n, context);
await options.execute?.();
@@ -146,7 +158,12 @@ const fixture = async (
},
options.maximumInputElements,
);
const client = new Client({ name: "deferred-fixture-client", version: "1" });
const client = new Client(
{ name: "deferred-fixture-client", version: "1" },
options.mode === undefined
? undefined
: { versionNegotiation: { mode: options.mode } },
);
if (options.progressive) server.enableProgressiveCatalog();
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
@@ -300,6 +317,47 @@ it("preserves output validation after deferred registration", async () => {
expect(result.isError).toBe(true);
});
it.each(["legacy", "auto"] as const)(
"canonically validates deferred object-union outputs over %s",
async (mode) => {
const valid = await fixture({ unionOutput: true, mode });
const before = await valid.client.listTools();
const schema = before.tools.find(
({ name }) => name === "fixture",
)?.outputSchema;
if (schema === undefined) throw new Error("Missing union output schema");
expect(schema).toHaveProperty("anyOf");
expect(schema).not.toHaveProperty("properties.result");
if (mode === "legacy") expect(schema.type).toBe("object");
expect(valid.loads()).toBe(0);
const success = await valid.client.callTool({
name: "fixture",
arguments: { n: 7 },
});
expect(success.isError).not.toBe(true);
expect(success.structuredContent).toEqual({ n: 7 });
expect(valid.calls).toHaveBeenCalledOnce();
expect(await valid.client.listTools()).toEqual(before);
const invalid = await fixture({
unionOutput: true,
invalidOutput: true,
mode,
});
const rejected = await invalid.client.callTool({
name: "fixture",
arguments: {},
});
// The handler actually returned malformed data. A server-side validation
// error must arrive, rather than success or only a client-validator throw.
expect(invalid.calls).toHaveBeenCalledOnce();
expect(rejected.isError).toBe(true);
expect(rejected.structuredContent).toBeUndefined();
expect(invalid.loads()).toBe(1);
expect(await invalid.client.listTools()).toEqual(before);
},
);
it("initializes through prompt retrieval or completion without an internal client", async () => {
const f = await fixture();
expect(
+75 -3
View File
@@ -24,6 +24,10 @@ import { HOT_TOOL_NAMES } from "../../../src/catalogDiscovery.js";
import { DatabaseSync } from "node:sqlite";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { AnySchemaObject } from "ajv";
import {
searchGuidanceOutputSchema,
readGuidanceSectionOutputSchema,
} from "../../../src/contracts/guidanceContracts.js";
const connect = async (
mode: "legacy" | "auto",
@@ -277,7 +281,35 @@ it.each([
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
});
const before = await client.listTools();
expect(before.tools).toEqual(recorded.tools);
// Compare independently composed canonical SDK registrations as well as
// recorded artifacts. Listing every explicit family selects eager startup
// with the same inventory, without target analysis or provider acquisition.
const eager = await connect(mode, {
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
REA_MCP_TOOL_FAMILIES: TOOL_FAMILY_DEFINITIONS.map(({ id }) => id).join(
",",
),
});
expect(before).toEqual(await eager.client.listTools());
const withoutOutput = ({
outputSchema: _output,
...tool
}: {
outputSchema?: unknown;
}) => tool;
expect(before.tools.map(withoutOutput)).toEqual(
recorded.tools.map(withoutOutput),
);
const outputs = new Map(
(mode === "auto" ? recorded.tools : GENERATED_MCP_TOOL_CATALOG).map(
(tool: { name: string; outputSchema?: unknown }) => [
tool.name,
tool.outputSchema,
],
),
);
for (const tool of before.tools)
expect(tool.outputSchema, tool.name).toEqual(outputs.get(tool.name));
const contractProjection = (tool: {
name: string;
title?: string | undefined;
@@ -293,7 +325,7 @@ it.each([
outputSchema: tool.outputSchema,
annotations: tool.annotations,
});
if (profile === "full")
if (profile === "full" && mode === "legacy")
expect(
new Map(
before.tools.map((tool) => [tool.name, contractProjection(tool)]),
@@ -358,6 +390,38 @@ it.each([
},
);
it.each(["legacy", "auto"] as const)(
"validates non-object-root guidance results from deferred startup over %s",
async (mode) => {
const { client, stderr } = await connect(mode);
const before = await client.listTools();
const unwrap = (value: unknown) =>
typeof value === "object" && value !== null && "result" in value
? value.result
: value;
const found = await client.callTool({
name: "search_guidance",
arguments: { query: "exact JavaScript module", limit: 1 },
});
expect(found.isError).not.toBe(true);
const matches = searchGuidanceOutputSchema.parse(
unwrap(found.structuredContent),
);
if (matches.status !== "matches") throw new Error("Expected guide matches");
const read = await client.callTool({
name: "read_guidance_section",
arguments: { locator: matches.matches[0]?.locator },
});
expect(read.isError).not.toBe(true);
expect(
readGuidanceSectionOutputSchema.parse(unwrap(read.structuredContent))
.status,
).toBe("section");
expect(await client.listTools()).toEqual(before);
expect(stderr()).toBe("");
},
);
it("composes a scoped registration instead of serving the recorded catalog", async () => {
const { client, stderr } = await connect("legacy", {
REA_MCP_TOOL_FAMILIES: "session",
@@ -729,7 +793,12 @@ it.each([{}, { REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" }])(
({ outputSchema: _output, ...tool }: Record<string, unknown>) =>
tool,
)
: catalog.tools;
: catalog.tools.map((tool: { name: string }) => ({
...tool,
outputSchema: GENERATED_MCP_TOOL_CATALOG.find(
({ name }) => name === tool.name,
)?.outputSchema,
}));
expect(before.tools).toEqual(expected);
const toolDefinition = before.tools.find(
({ name }) => name === "list_unknowns",
@@ -806,6 +875,7 @@ const RECORDER_RUNTIME_MODULES = [
"contracts/promptContracts.js",
"contracts/toolContracts.js",
"domain/evidenceInputContract.js",
"contracts/toolSchemaMetadata.js",
] as const;
// Recorder cleanup tests replace every runtime import through their loader
@@ -918,6 +988,8 @@ it.each([
"/dist/contracts/toolContracts.js": "export const TOOL_CONTRACTS = [];",
"/dist/domain/evidenceInputContract.js":
"export const getEvidenceInputContract = () => ({ hasEvidenceReferenceAlternative: false });",
"/dist/contracts/toolSchemaMetadata.js":
"export const toolOutputSchemaWithMetadata = () => { throw new Error('No fixture tools'); };",
};
const clientModule = `data:text/javascript,${encodeURIComponent(`
export class Client {