Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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.
Expand Down
250 changes: 249 additions & 1 deletion packages/plugins/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,6 +17,251 @@ const pluginManifest: PluginManifestOptions = {
signingKey: signingKeys.privateKey,
};

describe("McpApp tool outputs", () => {
let app: ReturnType<typeof mcp>;
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<AbortSignal>();
const cancelled = Promise.withResolvers<void>();
app.tool("wait", {
...(withInput && { input: z.object({}) }),
output: z.object({ done: z.boolean() }),
handler: async (_input, { signal }) => {
started.resolve(signal);
await new Promise<void>((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<unknown, unknown>("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<typeof mcp>;

Expand Down
Loading
Loading