diff --git a/README.md b/README.md index 2b757e2..a395b72 100644 --- a/README.md +++ b/README.md @@ -95,13 +95,33 @@ app.tool("add", { a: z.number(), b: z.number(), }), + output: z.object({ sum: z.number() }), handler: async ({ a, b }) => { return { sum: a + b }; }, }); ``` -Tools without input: +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. 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 an input schema receive `{}` as their handler's first argument: ```typescript app.tool("ping", { @@ -112,6 +132,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..613eeef 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,251 @@ 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(input).toEqual({}); + 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("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() }), + 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..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"; @@ -39,11 +41,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; + /** 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) => 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 +78,35 @@ export interface McpAppOptions { middleware?: Middleware[]; } +/** 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 { + const jsonSchema = z.toJSONSchema(schema, { + target: "draft-7", + io: "output", + unrepresentable: "throw", + override: ({ zodSchema }) => { + // 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"); + } + }, + }); + // Zod and the SDK use different JSON Schema typings; AJV checks the converted contract. + return new AjvJsonSchemaValidator().getValidator(jsonSchema as JsonSchemaType); + } catch { + return undefined; + } +} + type ToolRegistration = { name: string; opts: ToolOptions; + validateOutput: JsonSchemaValidator | undefined; }; type ResourceRegistration = { @@ -111,7 +146,7 @@ export class McpApp { const server = new McpServer(serverInfo, serverOptions); // Register all tools - for (const { name, opts } 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; @@ -126,11 +161,26 @@ export class McpApp { inputSchema: opts.input ? (opts.input as any).shape || opts.input : undefined, + ...(validateOutput && { 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(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: [ { @@ -138,6 +188,9 @@ export class McpApp { text: JSON.stringify(result), }, ], + ...(validateOutput && { + structuredContent: result as Record, + }), }; } ); @@ -170,6 +223,7 @@ export class McpApp { this.toolRegistrations.push({ name, opts: opts as ToolOptions, + validateOutput: compileOutputValidator(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); });