Skip to content

Commit 7ce0c17

Browse files
authored
Merge pull request #17 from code-rabi/benr/feat-support-annotation-registration
feat: support annotation registration
2 parents a7280da + 1c30218 commit 7ce0c17

5 files changed

Lines changed: 203 additions & 11 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "toolception",
3-
"version": "0.5.4",
3+
"version": "0.5.5",
44
"private": false,
55
"type": "module",
66
"main": "dist/index.js",

src/core/DynamicToolManager.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,31 @@ export class DynamicToolManager {
205205
* @private
206206
*/
207207
private registerSingleTool(tool: McpToolDefinition, toolsetKey: string): void {
208-
this.server.tool(
209-
tool.name,
210-
tool.description,
211-
tool.inputSchema as Parameters<typeof this.server.tool>[2],
212-
async (args: Record<string, unknown>) => {
213-
return await tool.handler(args);
214-
}
215-
);
208+
// Only pass annotations if they exist and are not empty
209+
const hasAnnotations =
210+
tool.annotations && Object.keys(tool.annotations).length > 0;
211+
212+
if (hasAnnotations && tool.annotations) {
213+
this.server.tool(
214+
tool.name,
215+
tool.description,
216+
tool.inputSchema as Parameters<typeof this.server.tool>[2],
217+
tool.annotations,
218+
async (args: Record<string, unknown>) => {
219+
return await tool.handler(args);
220+
}
221+
);
222+
} else {
223+
// Legacy 4-parameter call when no annotations
224+
this.server.tool(
225+
tool.name,
226+
tool.description,
227+
tool.inputSchema as Parameters<typeof this.server.tool>[2],
228+
async (args: Record<string, unknown>) => {
229+
return await tool.handler(args);
230+
}
231+
);
232+
}
216233
this.toolRegistry.addForToolset(toolsetKey, tool.name);
217234
}
218235

src/types/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ export type McpToolDefinition = {
77
description: string;
88
inputSchema: Record<string, any>;
99
handler: (args: any) => Promise<any> | any;
10+
/**
11+
* Optional annotations providing hints about tool behavior.
12+
* All annotations are hints and not guaranteed.
13+
* - title: Optional display title for the tool
14+
* - readOnlyHint: Tool does not modify its environment
15+
* - destructiveHint: Tool may perform destructive updates
16+
* - idempotentHint: Repeated calls with same arguments have no additional effect
17+
* - openWorldHint: Tool interacts with external entities (APIs, web, etc.)
18+
*/
19+
annotations?: {
20+
title?: string;
21+
destructiveHint?: boolean;
22+
idempotentHint?: boolean;
23+
readOnlyHint?: boolean;
24+
openWorldHint?: boolean;
25+
};
1026
};
1127

1228
export type ToolSetDefinition = {

tests/dynamicToolManager.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,146 @@ describe("DynamicToolManager", () => {
133133
expect(res.results.length).toBe(2);
134134
expect(res.success).toBe(true);
135135
});
136+
137+
it("registers tools with annotations when provided", async () => {
138+
const { server, tools } = createFakeMcpServer();
139+
const catalogWithAnnotations = {
140+
annotated: {
141+
name: "Annotated Tools",
142+
description: "Tools with annotations",
143+
tools: [
144+
{
145+
name: "read_data",
146+
description: "Read-only tool",
147+
inputSchema: {},
148+
handler: async () => ({ data: "test" }),
149+
annotations: {
150+
readOnlyHint: true,
151+
idempotentHint: true,
152+
},
153+
},
154+
{
155+
name: "delete_data",
156+
description: "Destructive tool",
157+
inputSchema: {},
158+
handler: async () => ({ deleted: true }),
159+
annotations: {
160+
destructiveHint: true,
161+
idempotentHint: false,
162+
},
163+
},
164+
{
165+
name: "fetch_weather",
166+
description: "External API tool",
167+
inputSchema: {},
168+
handler: async () => ({ weather: "sunny" }),
169+
annotations: {
170+
readOnlyHint: true,
171+
openWorldHint: true,
172+
idempotentHint: false,
173+
},
174+
},
175+
],
176+
},
177+
} as any;
178+
179+
const resolver = new ModuleResolver({ catalog: catalogWithAnnotations });
180+
const manager = new DynamicToolManager({
181+
server,
182+
resolver,
183+
toolRegistry: new ToolRegistry({ namespaceWithToolset: true }),
184+
});
185+
186+
const res = await manager.enableToolset("annotated");
187+
expect(res.success).toBe(true);
188+
189+
// Verify all tools were registered with correct annotations
190+
expect(tools.length).toBe(3);
191+
192+
const readTool = tools.find((t) => t.name === "annotated.read_data");
193+
expect(readTool).toBeDefined();
194+
expect(readTool?.annotations).toEqual({
195+
readOnlyHint: true,
196+
idempotentHint: true,
197+
});
198+
199+
const deleteTool = tools.find((t) => t.name === "annotated.delete_data");
200+
expect(deleteTool).toBeDefined();
201+
expect(deleteTool?.annotations).toEqual({
202+
destructiveHint: true,
203+
idempotentHint: false,
204+
});
205+
206+
const fetchTool = tools.find((t) => t.name === "annotated.fetch_weather");
207+
expect(fetchTool).toBeDefined();
208+
expect(fetchTool?.annotations).toEqual({
209+
readOnlyHint: true,
210+
openWorldHint: true,
211+
idempotentHint: false,
212+
});
213+
});
214+
215+
it("registers tools without annotations when not provided", async () => {
216+
const { server, tools } = createFakeMcpServer();
217+
const resolver = new ModuleResolver({ catalog });
218+
const manager = new DynamicToolManager({
219+
server,
220+
resolver,
221+
toolRegistry: new ToolRegistry({ namespaceWithToolset: true }),
222+
});
223+
224+
const res = await manager.enableToolset("core");
225+
expect(res.success).toBe(true);
226+
227+
const tool = tools.find((t) => t.name === "core.ping");
228+
expect(tool).toBeDefined();
229+
expect(tool?.annotations).toBeUndefined();
230+
});
231+
232+
it("registers module-loaded tools with annotations", async () => {
233+
const { server, tools } = createFakeMcpServer();
234+
const catalogWithModules = {
235+
external: {
236+
name: "External",
237+
description: "External tools",
238+
modules: ["external"],
239+
},
240+
} as any;
241+
242+
const resolver = new ModuleResolver({
243+
catalog: catalogWithModules,
244+
moduleLoaders: {
245+
external: async () => [
246+
{
247+
name: "api_call",
248+
description: "Call external API",
249+
inputSchema: {},
250+
handler: async () => ({ result: "ok" }),
251+
annotations: {
252+
openWorldHint: true,
253+
readOnlyHint: false,
254+
idempotentHint: false,
255+
},
256+
},
257+
],
258+
},
259+
});
260+
261+
const manager = new DynamicToolManager({
262+
server,
263+
resolver,
264+
toolRegistry: new ToolRegistry({ namespaceWithToolset: true }),
265+
});
266+
267+
const res = await manager.enableToolset("external");
268+
expect(res.success).toBe(true);
269+
270+
const tool = tools.find((t) => t.name === "external.api_call");
271+
expect(tool).toBeDefined();
272+
expect(tool?.annotations).toEqual({
273+
openWorldHint: true,
274+
readOnlyHint: false,
275+
idempotentHint: false,
276+
});
277+
});
136278
});

tests/helpers/fakes.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,31 @@ export type RegisteredTool = {
22
name: string;
33
description: string;
44
schema: any;
5+
annotations?: {
6+
title?: string;
7+
destructiveHint?: boolean;
8+
idempotentHint?: boolean;
9+
readOnlyHint?: boolean;
10+
openWorldHint?: boolean;
11+
};
512
handler: (args: any) => Promise<any> | any;
613
};
714

815
export function createFakeMcpServer(options: { withNotifier?: boolean } = {}) {
916
const tools: RegisteredTool[] = [];
1017
const server: any = {
11-
tool(name: string, description: string, schema: any, handler: any) {
12-
tools.push({ name, description, schema, handler });
18+
tool(
19+
name: string,
20+
description: string,
21+
schema: any,
22+
annotationsOrHandler: any,
23+
maybeHandler?: any
24+
) {
25+
// Support both 4-param (legacy) and 5-param (with annotations) signatures
26+
const isLegacy = typeof annotationsOrHandler === "function";
27+
const annotations = isLegacy ? undefined : annotationsOrHandler;
28+
const handler = isLegacy ? annotationsOrHandler : maybeHandler;
29+
tools.push({ name, description, schema, annotations, handler });
1330
},
1431
};
1532
if (options.withNotifier) {

0 commit comments

Comments
 (0)