From 07eac1fa4b392875117449938e84cfb9119efd68 Mon Sep 17 00:00:00 2001 From: Jonathan Adithya Date: Fri, 16 Oct 2026 11:56:41 -0700 Subject: [PATCH 1/2] feat: support structured tool outputs and cancellation --- README.md | 32 ++++ packages/plugins/src/app.test.ts | 181 +++++++++++++++++++- packages/plugins/src/app.ts | 52 +++++- packages/plugins/src/pluginManifest.test.ts | 2 +- 4 files changed, 261 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2b757e2..beeddfc 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,28 @@ app.tool("add", { a: z.number(), b: z.number(), }), + output: z.object({ sum: z.number() }), handler: async ({ a, b }) => { return { sum: a + b }; }, }); ``` +An optional `output` declares the **final handler result**, not the upstream +response. Supported Zod object schemas are advertised as MCP `outputSchema`; +successful calls return the same parsed result as `structuredContent` and JSON +text in `content`. Output parsing validates the complete schema, including async +refinements and strict-object rules, applies defaults, and strips unknown fields +unless the object schema preserves or rejects them. Invalid results become tool +errors. The MCP SDK also validates the normalized structured content. + +Schemas must be directly object-shaped and convertible to draft-7 JSON Schema in +output mode. Non-object schemas, unrepresentable fields (such as `z.date()`), and +pipelines (including +transforms and preprocessors, even nested ones) retain text-only behavior with +no advertised schema or SDK output validation. Tools without `output` are +unchanged. Wrap arrays or primitives in an object to expose structured output. + Tools without input: ```typescript @@ -112,6 +128,22 @@ app.tool("ping", { }); ``` +Handlers also receive a per-call `ToolContext` with an `AbortSignal`: + +```typescript +app.tool("status", { + handler: async (_input, { signal }) => { + const response = await fetch("https://example.com/status", { signal }); + return response.json(); + }, +}); +``` + +The signal aborts when the MCP client cancels that request or the SDK connection +closes. Cancellation is cooperative: pass the signal to downstream I/O or check +`signal.throwIfAborted()` during work. It does not undo completed side effects. +Existing handlers that accept only the input remain supported. + ### `app.resource(uri, options)` Register a resource. diff --git a/packages/plugins/src/app.test.ts b/packages/plugins/src/app.test.ts index cd7d922..9e3b915 100644 --- a/packages/plugins/src/app.test.ts +++ b/packages/plugins/src/app.test.ts @@ -1,4 +1,7 @@ -import { test, expect, describe, afterEach } from "bun:test"; +import { test, expect, describe, afterEach, beforeEach } from "bun:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; import { mcp } from "./app"; import { generateKeyPair, verifyPayload } from "./signing"; import type { PluginManifestOptions } from "./pluginManifest"; @@ -14,6 +17,182 @@ const pluginManifest: PluginManifestOptions = { signingKey: signingKeys.privateKey, }; +describe("McpApp tool outputs", () => { + let app: ReturnType; + let client: Client; + + beforeEach(() => { + app = mcp({ name: "test", version: "1.0.0", pluginManifest }); + client = new Client({ name: "test-client", version: "1.0.0" }); + }); + + afterEach(async () => { + await client.close(); + await app.stop(); + }); + + /** Connects through the public fetch API without opening a network listener. */ + async function connect() { + await client.connect(new StreamableHTTPClientTransport(new URL("http://localhost/mcp"), { + fetch: (url, init) => app.fetch(new Request(url.toString(), init)), + })); + } + + test("advertises object outputs and returns structured content alongside JSON text", async () => { + app.tool("add", { + input: z.object({ a: z.number(), b: z.number() }), + output: z.object({ sum: z.number().describe("The sum") }), + dangerLevel: "read", + handler: ({ a, b }, { signal }) => { + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + return { sum: a + b }; + }, + }); + await connect(); + const { tools } = await client.listTools(); + expect(tools[0]?.outputSchema).toMatchObject({ + type: "object", + properties: { sum: { type: "number", description: "The sum" } }, + required: ["sum"], + }); + expect(tools[0]?.annotations).toEqual({ readOnlyHint: true, destructiveHint: false }); + expect(tools[0]?._meta).toEqual({ "futurity:dangerLevel": "read" }); + expect(await client.callTool({ name: "add", arguments: { a: 2, b: 3 } })).toEqual({ + content: [{ type: "text", text: '{"sum":5}' }], + structuredContent: { sum: 5 }, + }); + }); + + test.each([false, true])("preserves cancellation context with input schema: %s", async (withInput) => { + const started = Promise.withResolvers(); + const cancelled = Promise.withResolvers(); + app.tool("wait", { + ...(withInput && { input: z.object({}) }), + output: z.object({ done: z.boolean() }), + handler: async (_input, { signal }) => { + started.resolve(signal); + await new Promise((resolve) => signal.addEventListener("abort", () => { + cancelled.resolve(); + resolve(); + }, { once: true })); + return { done: true }; + }, + }); + await connect(); + const controller = new AbortController(); + const call = client.callTool({ name: "wait", arguments: {} }, undefined, { signal: controller.signal }); + const outcome = call.catch(error => error); + const signal = await started.promise; + expect(signal.aborted).toBe(false); + controller.abort(); + expect(await outcome).toBeInstanceOf(Error); + await cancelled.promise; + expect(signal.aborted).toBe(true); + }); + + test("supports output schemas on tools without an input schema", async () => { + app.tool("ping", { + output: z.object({ status: z.literal("ok") }), + handler: (_input, { signal }) => { + expect(signal).toBeInstanceOf(AbortSignal); + return { status: "ok" as const }; + }, + }); + await connect(); + expect((await client.listTools()).tools[0]?.outputSchema?.type).toBe("object"); + expect(await client.callTool({ name: "ping" })).toEqual({ + content: [{ type: "text", text: '{"status":"ok"}' }], + structuredContent: { status: "ok" }, + }); + }); + + test.each([ + null, [], {}, { count: "wrong" }, { count: 0 }, { count: 1, extra: true }, + ])("rejects invalid final output %j using the complete schema", async (value) => { + app.tool("invalid", { + output: z.object({ count: z.number() }).strict().refine(async ({ count }) => count > 0), + // Deliberately simulate a malformed runtime result despite the static contract. + handler: () => value as { count: number }, + }); + await connect(); + await client.listTools(); + const result = await client.callTool({ name: "invalid" }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + expect(result.content).toEqual([{ + type: "text", text: expect.stringContaining("Output validation error"), + }]); + }); + + test.each([ + { label: "extra fields", value: { count: 4, extra: true } }, + { label: "missing defaults", value: {} }, + { label: "extra fields and missing defaults", value: { extra: true } }, + ])("normalizes $label after output schema discovery", async ({ value }) => { + app.tool("final", { + output: z.object({ count: z.number().default(4) }), + // Exercise runtime normalization despite the handler's static output contract. + handler: () => value as { count: number }, + }); + await connect(); + expect((await client.listTools()).tools[0]?.outputSchema).toMatchObject({ + type: "object", + required: ["count"], + additionalProperties: false, + }); + expect(await client.callTool({ name: "final" })).toEqual({ + content: [{ type: "text", text: '{"count":4}' }], + structuredContent: { count: 4 }, + }); + }); + + test("handler errors remain tool errors without structured content", async () => { + app.tool("fails", { + output: z.object({ ok: z.boolean() }), + handler: () => { throw new Error("Expected handler failure"); }, + }); + await connect(); + await client.listTools(); + expect(await client.callTool({ name: "fails" })).toEqual({ + isError: true, + content: [{ type: "text", text: "Expected handler failure" }], + }); + }); + + test.each([ + { label: "object", value: { ok: true } }, + { label: "array", value: [1, 2] }, + { label: "string", value: "hello" }, + { label: "null", value: null }, + ])("keeps undeclared $label outputs text-only", async ({ value }) => { + app.tool("legacy", { handler: () => value }); + await connect(); + expect((await client.listTools()).tools[0]?.outputSchema).toBeUndefined(); + expect(await client.callTool({ name: "legacy" })).toEqual({ + content: [{ type: "text", text: JSON.stringify(value) }], + }); + }); + + test.each([ + { label: "scalar", output: z.string(), value: "hello" }, + { label: "array", output: z.array(z.number()), value: [1] }, + { label: "optional object", output: z.object({ n: z.number() }).optional(), value: { n: 1 } }, + { label: "object transform", output: z.string().transform(n => ({ n: Number(n) })), value: { n: 1 } }, + { label: "nested transform", output: z.object({ n: z.string().transform(Number) }), value: { n: 1 } }, + { label: "nested pipeline", output: z.object({ n: z.string().transform(Number).pipe(z.number()) }), value: { n: 1 } }, + { label: "preprocessor", output: z.object({ n: z.preprocess(Number, z.number()) }), value: { n: 1 } }, + { label: "unrepresentable field", output: z.object({ date: z.date() }), value: { date: new Date(0) } }, + ])("keeps unsupported $label schemas discoverable and text-only", async ({ output, value }) => { + app.tool("unsupported", { output, handler: () => value }); + await connect(); + expect((await client.listTools()).tools[0]?.outputSchema).toBeUndefined(); + expect(await client.callTool({ name: "unsupported" })).toEqual({ + content: [{ type: "text", text: JSON.stringify(value) }], + }); + }); +}); + describe("McpApp.fetch", () => { let app: ReturnType; diff --git a/packages/plugins/src/app.ts b/packages/plugins/src/app.ts index 983c3f9..b36d72e 100644 --- a/packages/plugins/src/app.ts +++ b/packages/plugins/src/app.ts @@ -39,11 +39,18 @@ function buildAnnotations( }; } +/** Per-call context supplied to tool handlers. */ +export interface ToolContext { + /** Aborted on MCP request cancellation or connection closure. Pass to downstream I/O to stop work cooperatively. */ + signal: AbortSignal; +} + export interface ToolOptions { description?: string; input?: z.ZodType; + /** Advertises and parses final object results when JSON-Schema-representable and pipeline-free, applying defaults and object field rules to both response forms; otherwise retains text-only output. */ output?: z.ZodType; - handler: (input: I) => Promise | O; + handler: (input: I, context: ToolContext) => Promise | O; /** Tool danger level. Defaults to "read" if not specified. */ dangerLevel?: DangerLevel; /** Whether this tool requires explicit user consent before execution, regardless of the user's permission mode. */ @@ -69,9 +76,31 @@ export interface McpAppOptions { middleware?: Middleware[]; } +/** Whether MCP can advertise the schema and re-validate the handler's final object. */ +function declaresOutput(schema: z.ZodType | undefined): boolean { + if (schema?._zod.def.type !== "object") return false; + try { + z.toJSONSchema(schema, { + target: "draft-7", + io: "output", + unrepresentable: "throw", + override: ({ zodSchema }) => { + // Output conversion can hide a pipeline's input transforms, which MCP would run again. + if (zodSchema._zod.def.type === "pipe") { + throw new Error("Output pipelines require text-only results"); + } + }, + }); + return true; + } catch { + return false; + } +} + type ToolRegistration = { name: string; opts: ToolOptions; + structured: boolean; }; type ResourceRegistration = { @@ -111,7 +140,7 @@ export class McpApp { const server = new McpServer(serverInfo, serverOptions); // Register all tools - for (const { name, opts } of this.toolRegistrations) { + for (const { name, opts, structured } of this.toolRegistrations) { const annotations = buildAnnotations(opts); const meta: Record = {}; if (opts.dangerLevel) meta["futurity:dangerLevel"] = opts.dangerLevel; @@ -126,11 +155,22 @@ export class McpApp { inputSchema: opts.input ? (opts.input as any).shape || opts.input : undefined, + ...(structured && { outputSchema: opts.output }), ...(annotations && { annotations }), ...(Object.keys(meta).length > 0 && { _meta: meta }), }, - async (input: any) => { - const result = await opts.handler(input); + async (input: any, extra: ToolContext) => { + // The SDK passes context as the first argument for tools without an input schema. + const context = opts.input ? extra : input; + let result = await opts.handler(input, { signal: context.signal }); + if (structured) { + // MCP validates but discards parsed data; normalize both response forms here. + const parsed = await opts.output!.safeParseAsync(result); + if (!parsed.success) { + throw new Error(`Output validation error: Invalid structured content for tool ${name}: ${parsed.error.message}`); + } + result = parsed.data; + } return { content: [ { @@ -138,6 +178,9 @@ export class McpApp { text: JSON.stringify(result), }, ], + ...(structured && { + structuredContent: result as Record, + }), }; } ); @@ -170,6 +213,7 @@ export class McpApp { this.toolRegistrations.push({ name, opts: opts as ToolOptions, + structured: declaresOutput(opts.output), }); return this; } diff --git a/packages/plugins/src/pluginManifest.test.ts b/packages/plugins/src/pluginManifest.test.ts index b0ab529..779c87d 100644 --- a/packages/plugins/src/pluginManifest.test.ts +++ b/packages/plugins/src/pluginManifest.test.ts @@ -215,7 +215,7 @@ describe("GovernanceSchema", () => { credential: "user", datasets: { [id]: governance.datasets.people }, restrictedByDefault: [id], - }; + } satisfies Governance; expect(GovernanceSchema.parse(input)).toEqual(input); }); From 5d7d49f5692ff14f682ab20d8c372a9e4a3ce1e9 Mon Sep 17 00:00:00 2001 From: Jonathan Adithya Date: Tue, 20 Oct 2026 12:26:36 -0700 Subject: [PATCH 2/2] fix: isolate incompatible tool output schemas --- README.md | 20 +++++---- packages/plugins/src/app.test.ts | 71 +++++++++++++++++++++++++++++++- packages/plugins/src/app.ts | 44 ++++++++++++-------- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index beeddfc..a395b72 100644 --- a/README.md +++ b/README.md @@ -108,16 +108,20 @@ successful calls return the same parsed result as `structuredContent` and JSON text in `content`. Output parsing validates the complete schema, including async refinements and strict-object rules, applies defaults, and strips unknown fields unless the object schema preserves or rejects them. Invalid results become tool -errors. The MCP SDK also validates the normalized structured content. - -Schemas must be directly object-shaped and convertible to draft-7 JSON Schema in -output mode. Non-object schemas, unrepresentable fields (such as `z.date()`), and -pipelines (including -transforms and preprocessors, even nested ones) retain text-only behavior with -no advertised schema or SDK output validation. Tools without `output` are +errors. Parsed results are also checked against the generated JSON Schema using +the MCP SDK's default AJV validator, so mismatches (such as regex flags lost in +conversion) become tool errors rather than client validation failures. The MCP +SDK also validates the normalized structured content. + +Schemas must be directly object-shaped, convertible to draft-7 JSON Schema in +output mode, and compilable by the MCP SDK's default AJV validator. Non-object +schemas, unrepresentable fields (such as `z.date()`), invalid JSON Schema patterns, +pipelines (including transforms and preprocessors), and `.overwrite()` checks, +even nested ones, retain unparsed text-only behavior with no advertised schema or +output validation. Tools without `output` are unchanged. Wrap arrays or primitives in an object to expose structured output. -Tools without input: +Tools without an input schema receive `{}` as their handler's first argument: ```typescript app.tool("ping", { diff --git a/packages/plugins/src/app.test.ts b/packages/plugins/src/app.test.ts index 9e3b915..613eeef 100644 --- a/packages/plugins/src/app.test.ts +++ b/packages/plugins/src/app.test.ts @@ -94,7 +94,8 @@ describe("McpApp tool outputs", () => { test("supports output schemas on tools without an input schema", async () => { app.tool("ping", { output: z.object({ status: z.literal("ok") }), - handler: (_input, { signal }) => { + handler: (input, { signal }) => { + expect(input).toEqual({}); expect(signal).toBeInstanceOf(AbortSignal); return { status: "ok" as const }; }, @@ -147,6 +148,74 @@ describe("McpApp tool outputs", () => { }); }); + test("uncompilable output schemas do not hide other tools", async () => { + app.tool("invalid-pattern", { + output: z.object({ value: z.string().regex(/a\-/) }), + handler: () => ({ value: "a-" }), + }); + app.tool("healthy", { + output: z.object({ ok: z.boolean() }), + handler: () => ({ ok: true }), + }); + await connect(); + const { tools } = await client.listTools(); + expect(tools.map(tool => tool.name)).toEqual(["invalid-pattern", "healthy"]); + expect(tools[0]?.outputSchema).toBeUndefined(); + expect(tools[1]?.outputSchema?.type).toBe("object"); + expect(await client.callTool({ name: "invalid-pattern" })).toEqual({ + content: [{ type: "text", text: '{"value":"a-"}' }], + }); + expect((await client.callTool({ name: "healthy" })).structuredContent).toEqual({ ok: true }); + }); + + test("JSON Schema mismatches return tool errors instead of failing client validation", async () => { + app.tool("case-insensitive", { + input: z.object({ value: z.string() }), + output: z.object({ value: z.string().regex(/^abc$/i) }), + handler: ({ value }) => ({ value }), + }); + await connect(); + await client.listTools(); + const result = await client.callTool({ name: "case-insensitive", arguments: { value: "ABC" } }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + expect(result.content).toEqual([{ + type: "text", text: expect.stringContaining("Output validation error"), + }]); + expect(await client.callTool({ name: "case-insensitive", arguments: { value: "abc" } })).toEqual({ + content: [{ type: "text", text: '{"value":"abc"}' }], + structuredContent: { value: "abc" }, + }); + }); + + test("local datetimes accepted by both validators retain structured output", async () => { + app.tool("local-time", { + output: z.object({ at: z.iso.datetime({ local: true }) }), + handler: () => ({ at: "2026-01-01T12:00:00" }), + }); + await connect(); + expect((await client.listTools()).tools[0]?.outputSchema?.type).toBe("object"); + expect(await client.callTool({ name: "local-time" })).toEqual({ + content: [{ type: "text", text: '{"at":"2026-01-01T12:00:00"}' }], + structuredContent: { at: "2026-01-01T12:00:00" }, + }); + }); + + test.each([false, true])("overwrite schemas remain text-only without applying rewrites (root: %s)", async (root) => { + let rewrites = 0; + const rewrite = (n: number) => { rewrites++; return n + 1; }; + const output = root + ? z.object({ n: z.number().max(2) }).overwrite(({ n }) => ({ n: rewrite(n) })) + : z.object({ n: z.number().overwrite(rewrite).max(2) }); + app.tool("overwrite", { output, handler: () => ({ n: 1 }) }); + await connect(); + expect((await client.listTools()).tools[0]?.outputSchema).toBeUndefined(); + expect(await client.callTool({ name: "overwrite" })).toEqual({ + content: [{ type: "text", text: '{"n":1}' }], + }); + expect(rewrites).toBe(0); + }); + test("handler errors remain tool errors without structured content", async () => { app.tool("fails", { output: z.object({ ok: z.boolean() }), diff --git a/packages/plugins/src/app.ts b/packages/plugins/src/app.ts index b36d72e..f131aa3 100644 --- a/packages/plugins/src/app.ts +++ b/packages/plugins/src/app.ts @@ -4,6 +4,8 @@ import type { Implementation, ToolAnnotations, } from "@modelcontextprotocol/sdk/types.js"; +import type { JsonSchemaType, JsonSchemaValidator } from "@modelcontextprotocol/sdk/validation"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; import { z } from "zod"; import { createChainedAuthRouter } from "./chainedAuth"; import { OAuthOptionsSchema, type OAuthOptions } from "./oauth"; @@ -48,7 +50,7 @@ export interface ToolContext { export interface ToolOptions { description?: string; input?: z.ZodType; - /** Advertises and parses final object results when JSON-Schema-representable and pipeline-free, applying defaults and object field rules to both response forms; otherwise retains text-only output. */ + /** Parses final object results and validates their advertised JSON Schema. Unsupported schemas, including pipelines and overwrites, retain unparsed text-only output. */ output?: z.ZodType; handler: (input: I, context: ToolContext) => Promise | O; /** Tool danger level. Defaults to "read" if not specified. */ @@ -76,31 +78,35 @@ export interface McpAppOptions { middleware?: Middleware[]; } -/** Whether MCP can advertise the schema and re-validate the handler's final object. */ -function declaresOutput(schema: z.ZodType | undefined): boolean { - if (schema?._zod.def.type !== "object") return false; +/** Compiles the client's output contract, or selects text-only output for unsupported schemas. */ +function compileOutputValidator( + schema: z.ZodType | undefined +): JsonSchemaValidator | undefined { + if (schema?._zod.def.type !== "object") return undefined; try { - z.toJSONSchema(schema, { + const jsonSchema = z.toJSONSchema(schema, { target: "draft-7", io: "output", unrepresentable: "throw", override: ({ zodSchema }) => { - // Output conversion can hide a pipeline's input transforms, which MCP would run again. - if (zodSchema._zod.def.type === "pipe") { - throw new Error("Output pipelines require text-only results"); + // MCP re-parses normalized results, so rewrites must stay text-only. + const def = zodSchema._zod.def; + if (def.type === "pipe" || def.checks?.some(check => check._zod.def.check === "overwrite")) { + throw new Error("Output rewrites require text-only results"); } }, }); - return true; + // Zod and the SDK use different JSON Schema typings; AJV checks the converted contract. + return new AjvJsonSchemaValidator().getValidator(jsonSchema as JsonSchemaType); } catch { - return false; + return undefined; } } type ToolRegistration = { name: string; opts: ToolOptions; - structured: boolean; + validateOutput: JsonSchemaValidator | undefined; }; type ResourceRegistration = { @@ -140,7 +146,7 @@ export class McpApp { const server = new McpServer(serverInfo, serverOptions); // Register all tools - for (const { name, opts, structured } of this.toolRegistrations) { + for (const { name, opts, validateOutput } of this.toolRegistrations) { const annotations = buildAnnotations(opts); const meta: Record = {}; if (opts.dangerLevel) meta["futurity:dangerLevel"] = opts.dangerLevel; @@ -155,21 +161,25 @@ export class McpApp { inputSchema: opts.input ? (opts.input as any).shape || opts.input : undefined, - ...(structured && { outputSchema: opts.output }), + ...(validateOutput && { outputSchema: opts.output }), ...(annotations && { annotations }), ...(Object.keys(meta).length > 0 && { _meta: meta }), }, async (input: any, extra: ToolContext) => { // The SDK passes context as the first argument for tools without an input schema. const context = opts.input ? extra : input; - let result = await opts.handler(input, { signal: context.signal }); - if (structured) { + let result = await opts.handler(opts.input ? input : {}, { signal: context.signal }); + if (validateOutput) { // MCP validates but discards parsed data; normalize both response forms here. const parsed = await opts.output!.safeParseAsync(result); if (!parsed.success) { throw new Error(`Output validation error: Invalid structured content for tool ${name}: ${parsed.error.message}`); } result = parsed.data; + const validation = validateOutput(result); + if (!validation.valid) { + throw new Error(`Output validation error: Invalid structured content for tool ${name}: ${validation.errorMessage}`); + } } return { content: [ @@ -178,7 +188,7 @@ export class McpApp { text: JSON.stringify(result), }, ], - ...(structured && { + ...(validateOutput && { structuredContent: result as Record, }), }; @@ -213,7 +223,7 @@ export class McpApp { this.toolRegistrations.push({ name, opts: opts as ToolOptions, - structured: declaresOutput(opts.output), + validateOutput: compileOutputValidator(opts.output), }); return this; }