Skip to content

Commit 9f7ad30

Browse files
committed
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.
1 parent 3945b12 commit 9f7ad30

4 files changed

Lines changed: 118 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@codelitdev/mcp-server-kit": minor
3+
---
4+
5+
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.

‎packages/mcp-server-kit/src/create.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,83 @@ describe("createMcpServerKit", () => {
186186
]);
187187
});
188188

189+
it("advertises a tool's title, output schema and extra hints", async () => {
190+
const server = createMcpServerKit<Ctx>({
191+
name: "reference",
192+
version: "0.0.0",
193+
authenticate: oauth,
194+
async resolveContext({ principalId }) {
195+
return { ok: true, context: { principalId, tenantId: "tnt_a" } };
196+
},
197+
tools: [
198+
{
199+
name: "notes.get",
200+
title: "Get note",
201+
description: "Get a note",
202+
risk: "read",
203+
inputSchema: z.object({ noteId: z.string() }),
204+
outputSchema: z.object({ id: z.string(), title: z.string() }),
205+
annotations: {
206+
idempotentHint: true,
207+
openWorldHint: false,
208+
},
209+
async handler({ args }) {
210+
const { noteId } = args as { noteId: string };
211+
return noteId === "bad" ? { id: noteId } : { id: noteId, title: "Hi" };
212+
},
213+
},
214+
],
215+
});
216+
const client = await connect(server, "modern");
217+
const [tool] = (await client.listTools()).tools;
218+
expect(tool?.title).toBe("Get note");
219+
expect(tool?.outputSchema).toMatchObject({
220+
type: "object",
221+
properties: { id: { type: "string" }, title: { type: "string" } },
222+
});
223+
expect(tool?.outputSchema).not.toHaveProperty("$schema");
224+
expect(tool?.annotations).toEqual({
225+
idempotentHint: true,
226+
openWorldHint: false,
227+
readOnlyHint: true,
228+
});
229+
230+
const called = await client.callTool({
231+
name: "notes.get",
232+
arguments: { noteId: "n1" },
233+
});
234+
expect(called.structuredContent).toEqual({ id: "n1", title: "Hi" });
235+
// A result that breaks the advertised output schema is never returned as success.
236+
const broken = await rawCall(server, "notes.get", { noteId: "bad" });
237+
const body = broken.body as { error?: unknown; result?: { isError?: boolean } };
238+
expect(body.error ?? body.result?.isError).toBeTruthy();
239+
});
240+
241+
it("refuses an output schema that is not an object", () => {
242+
expect(() =>
243+
createMcpServerKit<Ctx>({
244+
name: "reference",
245+
version: "0.0.0",
246+
authenticate: oauth,
247+
async resolveContext({ principalId }) {
248+
return { ok: true, context: { principalId, tenantId: "tnt_a" } };
249+
},
250+
tools: [
251+
{
252+
name: "notes.count",
253+
description: "Count notes",
254+
risk: "read",
255+
inputSchema: z.object({}),
256+
outputSchema: z.number() as unknown as z.ZodType,
257+
async handler() {
258+
return 1;
259+
},
260+
},
261+
],
262+
}),
263+
).toThrow("MCP tool output schemas must describe an object");
264+
});
265+
189266
it("serves 2025-era Streamable HTTP clients without sessions", async () => {
190267
const server = kit();
191268
const client = await connect(server, "legacy");

‎packages/mcp-server-kit/src/create.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,26 @@ import {
1818
} from "@modelcontextprotocol/server";
1919
import type { z } from "zod";
2020
import { mcpCorsHeaders } from "./cors.js";
21-
import { toMcpInputSchema } from "./schema.js";
21+
import { toMcpInputSchema, toMcpObjectSchema } from "./schema.js";
2222
import { patchMcpAccept, readMcpResponse, toWebRequest } from "./streamable-http.js";
2323

2424
export type McpToolRisk = "read" | "write" | "destructive";
2525

2626
export type McpToolDefinition<Context> = {
2727
name: string;
28+
/** A human-readable name clients may show instead of `name`. */
29+
title?: string;
2830
description: string;
2931
risk: McpToolRisk;
3032
inputSchema: z.ZodType;
33+
/**
34+
* The shape of a successful result. Advertised in `tools/list`, and the
35+
* handler's result must match it: the SDK checks `structuredContent`
36+
* against it.
37+
*/
38+
outputSchema?: z.ZodType;
39+
/** Hints beside the ones `risk` sets (`readOnlyHint`, `destructiveHint`). */
40+
annotations?: Pick<ToolAnnotations, "idempotentHint" | "openWorldHint">;
3141
handler: (input: {
3242
context: Context;
3343
args: unknown;
@@ -78,9 +88,15 @@ const DEFAULT_CATALOG_CACHE_HINT: CacheHint = {
7888
cacheScope: "private",
7989
};
8090

81-
function toolAnnotations(risk: McpToolRisk): ToolAnnotations {
82-
if (risk === "read") return { readOnlyHint: true };
83-
return { readOnlyHint: false, destructiveHint: risk === "destructive" };
91+
function toolAnnotations(
92+
risk: McpToolRisk,
93+
hints: McpToolDefinition<unknown>["annotations"],
94+
): ToolAnnotations {
95+
const riskHints =
96+
risk === "read"
97+
? { readOnlyHint: true }
98+
: { readOnlyHint: false, destructiveHint: risk === "destructive" };
99+
return { ...hints, ...riskHints };
84100
}
85101

86102
/**
@@ -139,6 +155,9 @@ export function createMcpServerKit<Context>(
139155
const tools = options.tools.map((tool) => ({
140156
tool,
141157
inputSchema: toMcpInputSchema(tool.inputSchema),
158+
outputSchema: tool.outputSchema
159+
? toMcpObjectSchema(tool.outputSchema, "output")
160+
: undefined,
142161
}));
143162
const catalogCacheHint = options.catalogCacheHint ?? DEFAULT_CATALOG_CACHE_HINT;
144163

@@ -169,13 +188,15 @@ export function createMcpServerKit<Context>(
169188
},
170189
},
171190
);
172-
for (const { tool, inputSchema } of tools) {
191+
for (const { tool, inputSchema, outputSchema } of tools) {
173192
server.registerTool(
174193
tool.name,
175194
{
195+
...(tool.title ? { title: tool.title } : {}),
176196
description: tool.description,
177197
inputSchema,
178-
annotations: toolAnnotations(tool.risk),
198+
...(outputSchema ? { outputSchema } : {}),
199+
annotations: toolAnnotations(tool.risk, tool.annotations),
179200
_meta: { risk: tool.risk },
180201
},
181202
async (args, ctx) => {

‎packages/mcp-server-kit/src/schema.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,20 @@ import { zodToJsonSchema } from "zod-to-json-schema";
1111
* requires. Refs are inlined and `$schema` is dropped so clients read the
1212
* schema under MCP's default dialect (JSON Schema 2020-12).
1313
*/
14-
export function toMcpInputSchema(schema: z.ZodType): StandardSchemaWithJSON {
14+
export function toMcpObjectSchema(
15+
schema: z.ZodType,
16+
kind: "input" | "output" = "input",
17+
): StandardSchemaWithJSON {
1518
const { $schema: _dialect, ...json } = zodToJsonSchema(schema, {
1619
target: "jsonSchema7",
1720
$refStrategy: "none",
1821
}) as Record<string, unknown>;
1922
if (json.type !== "object") {
20-
throw new Error("MCP tool input schemas must describe an object");
23+
throw new Error(`MCP tool ${kind} schemas must describe an object`);
2124
}
2225
return fromJsonSchema(json as JsonSchemaType);
2326
}
27+
28+
export function toMcpInputSchema(schema: z.ZodType): StandardSchemaWithJSON {
29+
return toMcpObjectSchema(schema, "input");
30+
}

0 commit comments

Comments
 (0)