Skip to content

Commit aaf04c8

Browse files
committed
Add ToolAnnotations support to McpServer.tool() method
1 parent c6668c1 commit aaf04c8

File tree

4 files changed

+267
-5
lines changed

4 files changed

+267
-5
lines changed

src/examples/server/simpleStreamableHttp.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ server.tool(
2727
},
2828
],
2929
};
30+
},
31+
{
32+
title: 'Greeting Tool',
33+
readOnlyHint: true,
34+
openWorldHint: false
3035
}
3136
);
3237

@@ -67,6 +72,11 @@ server.tool(
6772
}
6873
],
6974
};
75+
},
76+
{
77+
title: 'Multiple Greeting Tool',
78+
readOnlyHint: true,
79+
openWorldHint: false
7080
}
7181
);
7282

src/server/mcp.test.ts

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

497624
test("should validate tool args", async () => {
498625
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

src/types.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,62 @@ export const PromptListChangedNotificationSchema = NotificationSchema.extend({
745745
});
746746

747747
/* Tools */
748+
/**
749+
* Additional properties describing a Tool to clients.
750+
*
751+
* NOTE: all properties in ToolAnnotations are **hints**.
752+
* They are not guaranteed to provide a faithful description of
753+
* tool behavior (including descriptive properties like `title`).
754+
*
755+
* Clients should never make tool use decisions based on ToolAnnotations
756+
* received from untrusted servers.
757+
*/
758+
export const ToolAnnotationsSchema = z
759+
.object({
760+
/**
761+
* A human-readable title for the tool.
762+
*/
763+
title: z.optional(z.string()),
764+
765+
/**
766+
* If true, the tool does not modify its environment.
767+
*
768+
* Default: false
769+
*/
770+
readOnlyHint: z.optional(z.boolean()),
771+
772+
/**
773+
* If true, the tool may perform destructive updates to its environment.
774+
* If false, the tool performs only additive updates.
775+
*
776+
* (This property is meaningful only when `readOnlyHint == false`)
777+
*
778+
* Default: true
779+
*/
780+
destructiveHint: z.optional(z.boolean()),
781+
782+
/**
783+
* If true, calling the tool repeatedly with the same arguments
784+
* will have no additional effect on the its environment.
785+
*
786+
* (This property is meaningful only when `readOnlyHint == false`)
787+
*
788+
* Default: false
789+
*/
790+
idempotentHint: z.optional(z.boolean()),
791+
792+
/**
793+
* If true, this tool may interact with an "open world" of external
794+
* entities. If false, the tool's domain of interaction is closed.
795+
* For example, the world of a web search tool is open, whereas that
796+
* of a memory tool is not.
797+
*
798+
* Default: true
799+
*/
800+
openWorldHint: z.optional(z.boolean()),
801+
})
802+
.passthrough();
803+
748804
/**
749805
* Definition for a tool the client can call.
750806
*/
@@ -767,6 +823,10 @@ export const ToolSchema = z
767823
properties: z.optional(z.object({}).passthrough()),
768824
})
769825
.passthrough(),
826+
/**
827+
* Optional additional tool information.
828+
*/
829+
annotations: z.optional(ToolAnnotationsSchema),
770830
})
771831
.passthrough();
772832

@@ -1239,6 +1299,8 @@ export type GetPromptResult = Infer<typeof GetPromptResultSchema>;
12391299
export type PromptListChangedNotification = Infer<typeof PromptListChangedNotificationSchema>;
12401300

12411301
/* Tools */
1302+
// Standard ToolAnnotations type
1303+
export type ToolAnnotations = Infer<typeof ToolAnnotationsSchema>;
12421304
export type Tool = Infer<typeof ToolSchema>;
12431305
export type ListToolsRequest = Infer<typeof ListToolsRequestSchema>;
12441306
export type ListToolsResult = Infer<typeof ListToolsResultSchema>;

0 commit comments

Comments
 (0)