diff --git a/.changeset/mcp-tool-output-schema.md b/.changeset/mcp-tool-output-schema.md new file mode 100644 index 0000000..212479a --- /dev/null +++ b/.changeset/mcp-tool-output-schema.md @@ -0,0 +1,5 @@ +--- +"@codelitdev/mcp-server-kit": minor +--- + +MCP tool definitions take an optional `title`, `outputSchema` (a Zod object schema) and `annotations` with `idempotentHint` and `openWorldHint`. `tools/list` advertises them, and the SDK checks a successful result's `structuredContent` against `outputSchema`. `risk` still sets `readOnlyHint` and `destructiveHint`. Tools without the new fields are unchanged. diff --git a/packages/mcp-server-kit/src/create.test.ts b/packages/mcp-server-kit/src/create.test.ts index 7244424..83c57a9 100644 --- a/packages/mcp-server-kit/src/create.test.ts +++ b/packages/mcp-server-kit/src/create.test.ts @@ -186,6 +186,83 @@ describe("createMcpServerKit", () => { ]); }); + it("advertises a tool's title, output schema and extra hints", async () => { + const server = createMcpServerKit({ + name: "reference", + version: "0.0.0", + authenticate: oauth, + async resolveContext({ principalId }) { + return { ok: true, context: { principalId, tenantId: "tnt_a" } }; + }, + tools: [ + { + name: "notes.get", + title: "Get note", + description: "Get a note", + risk: "read", + inputSchema: z.object({ noteId: z.string() }), + outputSchema: z.object({ id: z.string(), title: z.string() }), + annotations: { + idempotentHint: true, + openWorldHint: false, + }, + async handler({ args }) { + const { noteId } = args as { noteId: string }; + return noteId === "bad" ? { id: noteId } : { id: noteId, title: "Hi" }; + }, + }, + ], + }); + const client = await connect(server, "modern"); + const [tool] = (await client.listTools()).tools; + expect(tool?.title).toBe("Get note"); + expect(tool?.outputSchema).toMatchObject({ + type: "object", + properties: { id: { type: "string" }, title: { type: "string" } }, + }); + expect(tool?.outputSchema).not.toHaveProperty("$schema"); + expect(tool?.annotations).toEqual({ + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }); + + const called = await client.callTool({ + name: "notes.get", + arguments: { noteId: "n1" }, + }); + expect(called.structuredContent).toEqual({ id: "n1", title: "Hi" }); + // A result that breaks the advertised output schema is never returned as success. + const broken = await rawCall(server, "notes.get", { noteId: "bad" }); + const body = broken.body as { error?: unknown; result?: { isError?: boolean } }; + expect(body.error ?? body.result?.isError).toBeTruthy(); + }); + + it("refuses an output schema that is not an object", () => { + expect(() => + createMcpServerKit({ + name: "reference", + version: "0.0.0", + authenticate: oauth, + async resolveContext({ principalId }) { + return { ok: true, context: { principalId, tenantId: "tnt_a" } }; + }, + tools: [ + { + name: "notes.count", + description: "Count notes", + risk: "read", + inputSchema: z.object({}), + outputSchema: z.number() as unknown as z.ZodType, + async handler() { + return 1; + }, + }, + ], + }), + ).toThrow("MCP tool output schemas must describe an object"); + }); + it("serves 2025-era Streamable HTTP clients without sessions", async () => { const server = kit(); const client = await connect(server, "legacy"); diff --git a/packages/mcp-server-kit/src/create.ts b/packages/mcp-server-kit/src/create.ts index 40f16ff..efd224f 100644 --- a/packages/mcp-server-kit/src/create.ts +++ b/packages/mcp-server-kit/src/create.ts @@ -18,16 +18,26 @@ import { } from "@modelcontextprotocol/server"; import type { z } from "zod"; import { mcpCorsHeaders } from "./cors.js"; -import { toMcpInputSchema } from "./schema.js"; +import { toMcpInputSchema, toMcpObjectSchema } from "./schema.js"; import { patchMcpAccept, readMcpResponse, toWebRequest } from "./streamable-http.js"; export type McpToolRisk = "read" | "write" | "destructive"; export type McpToolDefinition = { name: string; + /** A human-readable name clients may show instead of `name`. */ + title?: string; description: string; risk: McpToolRisk; inputSchema: z.ZodType; + /** + * The shape of a successful result. Advertised in `tools/list`, and the + * handler's result must match it: the SDK checks `structuredContent` + * against it. + */ + outputSchema?: z.ZodType; + /** Hints beside the ones `risk` sets (`readOnlyHint`, `destructiveHint`). */ + annotations?: Pick; handler: (input: { context: Context; args: unknown; @@ -78,9 +88,15 @@ const DEFAULT_CATALOG_CACHE_HINT: CacheHint = { cacheScope: "private", }; -function toolAnnotations(risk: McpToolRisk): ToolAnnotations { - if (risk === "read") return { readOnlyHint: true }; - return { readOnlyHint: false, destructiveHint: risk === "destructive" }; +function toolAnnotations( + risk: McpToolRisk, + hints: McpToolDefinition["annotations"], +): ToolAnnotations { + const riskHints = + risk === "read" + ? { readOnlyHint: true } + : { readOnlyHint: false, destructiveHint: risk === "destructive" }; + return { ...hints, ...riskHints }; } /** @@ -139,6 +155,9 @@ export function createMcpServerKit( const tools = options.tools.map((tool) => ({ tool, inputSchema: toMcpInputSchema(tool.inputSchema), + outputSchema: tool.outputSchema + ? toMcpObjectSchema(tool.outputSchema, "output") + : undefined, })); const catalogCacheHint = options.catalogCacheHint ?? DEFAULT_CATALOG_CACHE_HINT; @@ -169,13 +188,15 @@ export function createMcpServerKit( }, }, ); - for (const { tool, inputSchema } of tools) { + for (const { tool, inputSchema, outputSchema } of tools) { server.registerTool( tool.name, { + ...(tool.title ? { title: tool.title } : {}), description: tool.description, inputSchema, - annotations: toolAnnotations(tool.risk), + ...(outputSchema ? { outputSchema } : {}), + annotations: toolAnnotations(tool.risk, tool.annotations), _meta: { risk: tool.risk }, }, async (args, ctx) => { diff --git a/packages/mcp-server-kit/src/schema.ts b/packages/mcp-server-kit/src/schema.ts index b88fe7a..bac5cfb 100644 --- a/packages/mcp-server-kit/src/schema.ts +++ b/packages/mcp-server-kit/src/schema.ts @@ -11,13 +11,20 @@ import { zodToJsonSchema } from "zod-to-json-schema"; * requires. Refs are inlined and `$schema` is dropped so clients read the * schema under MCP's default dialect (JSON Schema 2020-12). */ -export function toMcpInputSchema(schema: z.ZodType): StandardSchemaWithJSON { +export function toMcpObjectSchema( + schema: z.ZodType, + kind: "input" | "output" = "input", +): StandardSchemaWithJSON { const { $schema: _dialect, ...json } = zodToJsonSchema(schema, { target: "jsonSchema7", $refStrategy: "none", }) as Record; if (json.type !== "object") { - throw new Error("MCP tool input schemas must describe an object"); + throw new Error(`MCP tool ${kind} schemas must describe an object`); } return fromJsonSchema(json as JsonSchemaType); } + +export function toMcpInputSchema(schema: z.ZodType): StandardSchemaWithJSON { + return toMcpObjectSchema(schema, "input"); +}