From 9f7ad309d5c098a393ce8dbdcdcd930528953408 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 15:29:06 +0530 Subject: [PATCH] feat(mcp-server-kit): tool title, output schema and idempotent/open-world hints Products that advertise output schemas today (SendLit's 68 tools) lose them when they move onto the kit. Tool definitions now take an optional title, outputSchema and the two annotation hints risk does not set; tools/list advertises them and the SDK validates structuredContent against the output schema. --- .changeset/mcp-tool-output-schema.md | 5 ++ packages/mcp-server-kit/src/create.test.ts | 77 ++++++++++++++++++++++ packages/mcp-server-kit/src/create.ts | 33 ++++++++-- packages/mcp-server-kit/src/schema.ts | 11 +++- 4 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 .changeset/mcp-tool-output-schema.md 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"); +}