rea/tests/boundary/mcp/toolRegistrationOptions.tes...

153 lines
5.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../../../src/generatedMcpToolCatalog.js";
import { annotationsFromEffects } from "../../../src/contracts/toolEffects.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
describe("tool registration options", () => {
it("generates the build catalog from the SDK wire projection", async () => {
const server = new McpServer({ name: "catalog-test", version: "0" });
for (const contract of TOOL_CONTRACTS)
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({
content: [{ type: "text" as const, text: "catalog-only" }],
isError: true as const,
}),
);
const client = new Client({ name: "catalog-test", version: "0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools.map((tool) => ({
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema,
annotations: tool.annotations,
}));
for (const contract of TOOL_CONTRACTS) {
const tool = advertised.find(({ name }) => name === contract.name);
expect(tool, contract.name).toBeDefined();
expect(describesObject(tool?.inputSchema), contract.name).toBe(true);
expect(describesObject(tool?.outputSchema), contract.name).toBe(true);
expect(tool?.title?.trim().length, contract.name).toBeGreaterThan(0);
expect(tool?.description?.trim().length, contract.name).toBeGreaterThan(
0,
);
expect(JSON.stringify(tool?.outputSchema), contract.name).not.toContain(
'"result":{}',
);
expect(tool?.annotations, contract.name).toEqual(
annotationsFromEffects(contract.effects),
);
expect(contract.examples.length, contract.name).toBeGreaterThan(0);
for (const example of contract.examples) {
expect(example.title.trim().length, contract.name).toBeGreaterThan(0);
expect(
contract.inputSchema.safeParse(example.input).success,
`${contract.name}: ${example.title}`,
).toBe(true);
}
expect(tool?.inputSchema.examples, contract.name).toEqual(
contract.examples.map(({ input }) => input),
);
expect(
missingPropertyDescriptions(tool?.inputSchema),
contract.name,
).toEqual([]);
}
expect(advertised).toEqual(
GENERATED_MCP_TOOL_CATALOG.map((tool) => ({
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema,
annotations: tool.annotations,
})),
);
for (const [name, expected] of [
["binary_session", { readOnlyHint: true, openWorldHint: false }],
["read_bytes", { readOnlyHint: false, destructiveHint: false }],
["set_comment", { readOnlyHint: false }],
["unset_bookmark", { destructiveHint: true }],
[
"capture_process_scenario",
{ readOnlyHint: false, openWorldHint: true },
],
["inspect_artifact", { readOnlyHint: false, openWorldHint: true }],
["extract_artifact", { readOnlyHint: false, idempotentHint: false }],
["compare_web_captures", { readOnlyHint: false, openWorldHint: false }],
[
"compare_web_screenshots",
{ readOnlyHint: false, openWorldHint: false },
],
] as const) {
expect(
advertised.find((tool) => tool.name === name)?.annotations,
name,
).toMatchObject(expected);
}
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
});
const missingPropertyDescriptions = (
value: unknown,
path = "inputSchema",
): string[] => {
if (Array.isArray(value))
return value.flatMap((child, index) =>
missingPropertyDescriptions(child, `${path}[${index}]`),
);
if (!isObject(value)) return [];
const missing: string[] = [];
if (isObject(value.properties))
for (const [property, schema] of Object.entries(value.properties)) {
const propertyPath = `${path}.properties.${property}`;
if (!isObject(schema) || typeof schema.description !== "string")
missing.push(propertyPath);
missing.push(...missingPropertyDescriptions(schema, propertyPath));
}
for (const [key, child] of Object.entries(value))
if (
key !== "properties" &&
key !== "examples" &&
key !== "default" &&
key !== "const" &&
key !== "enum"
)
missing.push(...missingPropertyDescriptions(child, `${path}.${key}`));
return missing;
};
const isObject = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const describesObject = (schema: unknown): boolean => {
if (!isObject(schema)) return false;
return (
schema.type === "object" ||
[schema.oneOf, schema.anyOf].some(
(variants) =>
Array.isArray(variants) &&
variants.length > 0 &&
variants.every(
(variant: unknown) => isObject(variant) && variant.type === "object",
),
)
);
};