Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/mcp-tool-output-schema.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions packages/mcp-server-kit/src/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,83 @@ describe("createMcpServerKit", () => {
]);
});

it("advertises a tool's title, output schema and extra hints", async () => {
const server = createMcpServerKit<Ctx>({
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<Ctx>({
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");
Expand Down
33 changes: 27 additions & 6 deletions packages/mcp-server-kit/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context> = {
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<ToolAnnotations, "idempotentHint" | "openWorldHint">;
handler: (input: {
context: Context;
args: unknown;
Expand Down Expand Up @@ -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<unknown>["annotations"],
): ToolAnnotations {
const riskHints =
risk === "read"
? { readOnlyHint: true }
: { readOnlyHint: false, destructiveHint: risk === "destructive" };
return { ...hints, ...riskHints };
}

/**
Expand Down Expand Up @@ -139,6 +155,9 @@ export function createMcpServerKit<Context>(
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;

Expand Down Expand Up @@ -169,13 +188,15 @@ export function createMcpServerKit<Context>(
},
},
);
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) => {
Expand Down
11 changes: 9 additions & 2 deletions packages/mcp-server-kit/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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");
}
Loading