Skip to content

Commit c04842e

Browse files
authored
Merge pull request modelcontextprotocol#309 from modelcontextprotocol/basil/tool_annotations
Add ToolAnnotations support to McpServer.tool() method
2 parents f417691 + e53608e commit c04842e

File tree

4 files changed

+263
-7
lines changed

4 files changed

+263
-7
lines changed

src/examples/server/simpleStreamableHttp.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ const getServer = () => {
3232
}
3333
);
3434

35-
// Register a tool that sends multiple greetings with notifications
35+
// Register a tool that sends multiple greetings with notifications (with annotations)
3636
server.tool(
3737
'multi-greet',
3838
'A tool that sends different greetings with delays between them',
3939
{
4040
name: z.string().describe('Name to greet'),
4141
},
42+
{
43+
title: 'Multiple Greeting Tool',
44+
readOnlyHint: true,
45+
openWorldHint: false
46+
},
4247
async ({ name }, { sendNotification }): Promise<CallToolResult> => {
4348
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
4449

src/server/mcp.test.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ describe("tool()", () => {
404404
])
405405
});
406406

407-
test("should register tool with args schema", async () => {
407+
test("should register tool with params", async () => {
408408
const mcpServer = new McpServer({
409409
name: "test server",
410410
version: "1.0",
@@ -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

src/types.ts

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

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

@@ -1246,6 +1306,7 @@ export type GetPromptResult = Infer<typeof GetPromptResultSchema>;
12461306
export type PromptListChangedNotification = Infer<typeof PromptListChangedNotificationSchema>;
12471307

12481308
/* Tools */
1309+
export type ToolAnnotations = Infer<typeof ToolAnnotationsSchema>;
12491310
export type Tool = Infer<typeof ToolSchema>;
12501311
export type ListToolsRequest = Infer<typeof ListToolsRequestSchema>;
12511312
export type ListToolsResult = Infer<typeof ListToolsResultSchema>;

0 commit comments

Comments
 (0)