Skip to content

Commit 0096be4

Browse files
committed
Add ToolAnnotations support to McpServer.tool() method
1 parent f417691 commit 0096be4

File tree

4 files changed

+338
-16
lines changed

4 files changed

+338
-16
lines changed

src/examples/server/simpleStreamableHttp.ts

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,88 @@ const getServer = () => {
1313
version: '1.0.0',
1414
}, { capabilities: { logging: {} } });
1515

16-
// Register a simple tool that returns a greeting
17-
server.tool(
18-
'greet',
19-
'A simple greeting tool',
20-
{
21-
name: z.string().describe('Name to greet'),
22-
},
23-
async ({ name }): Promise<CallToolResult> => {
24-
return {
25-
content: [
26-
{
16+
// Register a simple tool that returns a greeting
17+
server.tool(
18+
'greet',
19+
'A simple greeting tool',
20+
{
21+
name: z.string().describe('Name to greet'),
22+
},
23+
async ({ name }): Promise<CallToolResult> => {
24+
return {
25+
content: [
26+
{
27+
type: 'text',
28+
text: `Hello, ${name}!`,
29+
},
30+
],
31+
};
32+
},
33+
{
34+
title: 'Greeting Tool',
35+
readOnlyHint: true,
36+
openWorldHint: false
37+
}
38+
);
39+
40+
// Register a tool that sends multiple greetings with notifications
41+
server.tool(
42+
'multi-greet',
43+
'A tool that sends different greetings with delays between them',
44+
{
45+
name: z.string().describe('Name to greet'),
46+
},
47+
async ({ name }, { sendNotification }): Promise<CallToolResult> => {
48+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
49+
50+
await sendNotification({
51+
method: "notifications/message",
52+
params: { level: "debug", data: `Starting multi-greet for ${name}` }
53+
});
54+
55+
await sleep(1000); // Wait 1 second before first greeting
56+
57+
await sendNotification({
58+
method: "notifications/message",
59+
params: { level: "info", data: `Sending first greeting to ${name}` }
60+
});
61+
62+
await sleep(1000); // Wait another second before second greeting
63+
64+
await sendNotification({
65+
method: "notifications/message",
66+
params: { level: "info", data: `Sending second greeting to ${name}` }
67+
});
68+
69+
return {
70+
content: [
71+
{
72+
type: 'text',
73+
text: `Good morning, ${name}!`,
74+
}
75+
],
76+
};
77+
},
78+
{
79+
title: 'Multiple Greeting Tool',
80+
readOnlyHint: true,
81+
openWorldHint: false
82+
}
83+
);
84+
85+
// Register a simple prompt
86+
server.prompt(
87+
'greeting-template',
88+
'A simple greeting prompt template',
89+
{
90+
name: z.string().describe('Name to include in greeting'),
91+
},
92+
async ({ name }): Promise<GetPromptResult> => {
93+
return {
94+
messages: [
95+
{
96+
role: 'user',
97+
content: {
2798
type: 'text',
2899
text: `Hello, ${name}!`,
29100
},

src/server/mcp.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,133 @@ describe("tool()", () => {
494494
expect(result.tools[0].name).toBe("test");
495495
expect(result.tools[0].description).toBe("Test description");
496496
});
497+
498+
test("should register tool with annotations", async () => {
499+
const mcpServer = new McpServer({
500+
name: "test server",
501+
version: "1.0",
502+
});
503+
const client = new Client({
504+
name: "test client",
505+
version: "1.0",
506+
});
507+
508+
mcpServer.tool("test", { title: "Test Tool", readOnlyHint: true }, async () => ({
509+
content: [
510+
{
511+
type: "text",
512+
text: "Test response",
513+
},
514+
],
515+
}));
516+
517+
const [clientTransport, serverTransport] =
518+
InMemoryTransport.createLinkedPair();
519+
520+
await Promise.all([
521+
client.connect(clientTransport),
522+
mcpServer.server.connect(serverTransport),
523+
]);
524+
525+
const result = await client.request(
526+
{
527+
method: "tools/list",
528+
},
529+
ListToolsResultSchema,
530+
);
531+
532+
expect(result.tools).toHaveLength(1);
533+
expect(result.tools[0].name).toBe("test");
534+
expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true });
535+
});
536+
537+
test("should register tool with params and annotations", async () => {
538+
const mcpServer = new McpServer({
539+
name: "test server",
540+
version: "1.0",
541+
});
542+
const client = new Client({
543+
name: "test client",
544+
version: "1.0",
545+
});
546+
547+
mcpServer.tool(
548+
"test",
549+
{ name: z.string() },
550+
{ title: "Test Tool", readOnlyHint: true },
551+
async ({ name }) => ({
552+
content: [{ type: "text", text: `Hello, ${name}!` }]
553+
})
554+
);
555+
556+
const [clientTransport, serverTransport] =
557+
InMemoryTransport.createLinkedPair();
558+
559+
await Promise.all([
560+
client.connect(clientTransport),
561+
mcpServer.server.connect(serverTransport),
562+
]);
563+
564+
const result = await client.request(
565+
{ method: "tools/list" },
566+
ListToolsResultSchema,
567+
);
568+
569+
expect(result.tools).toHaveLength(1);
570+
expect(result.tools[0].name).toBe("test");
571+
expect(result.tools[0].inputSchema).toMatchObject({
572+
type: "object",
573+
properties: { name: { type: "string" } }
574+
});
575+
expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true });
576+
});
577+
578+
test("should register tool with description, params, and annotations", async () => {
579+
const mcpServer = new McpServer({
580+
name: "test server",
581+
version: "1.0",
582+
});
583+
const client = new Client({
584+
name: "test client",
585+
version: "1.0",
586+
});
587+
588+
mcpServer.tool(
589+
"test",
590+
"A tool with everything",
591+
{ name: z.string() },
592+
{ title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false },
593+
async ({ name }) => ({
594+
content: [{ type: "text", text: `Hello, ${name}!` }]
595+
})
596+
);
597+
598+
const [clientTransport, serverTransport] =
599+
InMemoryTransport.createLinkedPair();
600+
601+
await Promise.all([
602+
client.connect(clientTransport),
603+
mcpServer.server.connect(serverTransport),
604+
]);
605+
606+
const result = await client.request(
607+
{ method: "tools/list" },
608+
ListToolsResultSchema,
609+
);
610+
611+
expect(result.tools).toHaveLength(1);
612+
expect(result.tools[0].name).toBe("test");
613+
expect(result.tools[0].description).toBe("A tool with everything");
614+
expect(result.tools[0].inputSchema).toMatchObject({
615+
type: "object",
616+
properties: { name: { type: "string" } }
617+
});
618+
expect(result.tools[0].annotations).toEqual({
619+
title: "Complete Test Tool",
620+
readOnlyHint: true,
621+
openWorldHint: false
622+
});
623+
});
497624

498625
test("should validate tool args", async () => {
499626
const mcpServer = new McpServer({

src/server/mcp.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
ReadResourceResult,
4040
ServerRequest,
4141
ServerNotification,
42+
ToolAnnotations,
4243
} from "../types.js";
4344
import { Completable, CompletableDef } from "./completable.js";
4445
import { UriTemplate, Variables } from "../shared/uriTemplate.js";
@@ -118,6 +119,7 @@ export class McpServer {
118119
strictUnions: true,
119120
}) as Tool["inputSchema"])
120121
: EMPTY_OBJECT_JSON_SCHEMA,
122+
annotations: tool.annotations,
121123
};
122124
},
123125
),
@@ -605,44 +607,103 @@ export class McpServer {
605607
tool(name: string, description: string, cb: ToolCallback): RegisteredTool;
606608

607609
/**
608-
* Registers a tool `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments.
610+
* Registers a tool taking either a parameter schema for validation or annotations for additional metadata.
611+
* This unified overload handles both `tool(name, paramsSchema, cb)` and `tool(name, annotations, cb)` cases.
612+
*
613+
* Note: We use a union type for the second parameter because TypeScript cannot reliably disambiguate
614+
* between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types.
609615
*/
610616
tool<Args extends ZodRawShape>(
611617
name: string,
612-
paramsSchema: Args,
618+
paramsSchemaOrAnnotations: Args | ToolAnnotations,
613619
cb: ToolCallback<Args>,
614620
): RegisteredTool;
615621

616622
/**
617-
* Registers a tool `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments.
623+
* Registers a tool `name` (with a description) taking either parameter schema or annotations.
624+
* This unified overload handles both `tool(name, description, paramsSchema, cb)` and
625+
* `tool(name, description, annotations, cb)` cases.
626+
*
627+
* Note: We use a union type for the third parameter because TypeScript cannot reliably disambiguate
628+
* between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types.
629+
*/
630+
tool<Args extends ZodRawShape>(
631+
name: string,
632+
description: string,
633+
paramsSchemaOrAnnotations: Args | ToolAnnotations,
634+
cb: ToolCallback<Args>,
635+
): RegisteredTool;
636+
637+
/**
638+
* Registers a tool with both parameter schema and annotations.
639+
*/
640+
tool<Args extends ZodRawShape>(
641+
name: string,
642+
paramsSchema: Args,
643+
annotations: ToolAnnotations,
644+
cb: ToolCallback<Args>,
645+
): RegisteredTool;
646+
647+
/**
648+
* Registers a tool with description, parameter schema, and annotations.
618649
*/
619650
tool<Args extends ZodRawShape>(
620651
name: string,
621652
description: string,
622653
paramsSchema: Args,
654+
annotations: ToolAnnotations,
623655
cb: ToolCallback<Args>,
624656
): RegisteredTool;
625657

626658
tool(name: string, ...rest: unknown[]): RegisteredTool {
627659
if (this._registeredTools[name]) {
628660
throw new Error(`Tool ${name} is already registered`);
629661
}
662+
663+
// Helper to check if an object is a Zod schema (ZodRawShape)
664+
const isZodRawShape = (obj: unknown): obj is ZodRawShape => {
665+
if (typeof obj !== "object" || obj === null) return false;
666+
// Check that at least one property is a ZodType instance
667+
return Object.values(obj as object).some(v => v instanceof ZodType);
668+
};
630669

631670
let description: string | undefined;
632671
if (typeof rest[0] === "string") {
633672
description = rest.shift() as string;
634673
}
635674

636675
let paramsSchema: ZodRawShape | undefined;
676+
let annotations: ToolAnnotations | undefined;
677+
678+
// Handle the different overload combinations
637679
if (rest.length > 1) {
638-
paramsSchema = rest.shift() as ZodRawShape;
680+
// We have at least two more args before the callback
681+
const firstArg = rest[0];
682+
683+
if (isZodRawShape(firstArg)) {
684+
// We have a params schema as the first arg
685+
paramsSchema = rest.shift() as ZodRawShape;
686+
687+
// Check if the next arg is potentially annotations
688+
if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) {
689+
// Case: tool(name, paramsSchema, annotations, cb)
690+
// Or: tool(name, description, paramsSchema, annotations, cb)
691+
annotations = rest.shift() as ToolAnnotations;
692+
}
693+
} else if (typeof firstArg === "object" && firstArg !== null) {
694+
// Not a ZodRawShape, so must be annotations in this position
695+
// Case: tool(name, annotations, cb)
696+
// Or: tool(name, description, annotations, cb)
697+
annotations = rest.shift() as ToolAnnotations;
698+
}
639699
}
640700

641701
const cb = rest[0] as ToolCallback<ZodRawShape | undefined>;
642702
const registeredTool: RegisteredTool = {
643703
description,
644704
inputSchema:
645705
paramsSchema === undefined ? undefined : z.object(paramsSchema),
706+
annotations,
646707
callback: cb,
647708
enabled: true,
648709
disable: () => registeredTool.update({ enabled: false }),
@@ -656,6 +717,7 @@ export class McpServer {
656717
if (typeof updates.description !== "undefined") registeredTool.description = updates.description
657718
if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema)
658719
if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback
720+
if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations
659721
if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled
660722
this.sendToolListChanged()
661723
},
@@ -853,11 +915,12 @@ export type ToolCallback<Args extends undefined | ZodRawShape = undefined> =
853915
export type RegisteredTool = {
854916
description?: string;
855917
inputSchema?: AnyZodObject;
918+
annotations?: ToolAnnotations;
856919
callback: ToolCallback<undefined | ZodRawShape>;
857920
enabled: boolean;
858921
enable(): void;
859922
disable(): void;
860-
update<Args extends ZodRawShape>(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback<Args>, enabled?: boolean }): void
923+
update<Args extends ZodRawShape>(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback<Args>, annotations?: ToolAnnotations, enabled?: boolean }): void
861924
remove(): void
862925
};
863926

0 commit comments

Comments
 (0)