diff --git a/package.json b/package.json index 325e987..0ea0b37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "toolception", - "version": "0.5.4", + "version": "0.5.5", "private": false, "type": "module", "main": "dist/index.js", diff --git a/src/core/DynamicToolManager.ts b/src/core/DynamicToolManager.ts index 69ed6cd..9144c4b 100644 --- a/src/core/DynamicToolManager.ts +++ b/src/core/DynamicToolManager.ts @@ -205,14 +205,31 @@ export class DynamicToolManager { * @private */ private registerSingleTool(tool: McpToolDefinition, toolsetKey: string): void { - this.server.tool( - tool.name, - tool.description, - tool.inputSchema as Parameters[2], - async (args: Record) => { - return await tool.handler(args); - } - ); + // Only pass annotations if they exist and are not empty + const hasAnnotations = + tool.annotations && Object.keys(tool.annotations).length > 0; + + if (hasAnnotations && tool.annotations) { + this.server.tool( + tool.name, + tool.description, + tool.inputSchema as Parameters[2], + tool.annotations, + async (args: Record) => { + return await tool.handler(args); + } + ); + } else { + // Legacy 4-parameter call when no annotations + this.server.tool( + tool.name, + tool.description, + tool.inputSchema as Parameters[2], + async (args: Record) => { + return await tool.handler(args); + } + ); + } this.toolRegistry.addForToolset(toolsetKey, tool.name); } diff --git a/src/types/index.ts b/src/types/index.ts index bdf10c6..5ba43ad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,22 @@ export type McpToolDefinition = { description: string; inputSchema: Record; handler: (args: any) => Promise | any; + /** + * Optional annotations providing hints about tool behavior. + * All annotations are hints and not guaranteed. + * - title: Optional display title for the tool + * - readOnlyHint: Tool does not modify its environment + * - destructiveHint: Tool may perform destructive updates + * - idempotentHint: Repeated calls with same arguments have no additional effect + * - openWorldHint: Tool interacts with external entities (APIs, web, etc.) + */ + annotations?: { + title?: string; + destructiveHint?: boolean; + idempotentHint?: boolean; + readOnlyHint?: boolean; + openWorldHint?: boolean; + }; }; export type ToolSetDefinition = { diff --git a/tests/dynamicToolManager.test.ts b/tests/dynamicToolManager.test.ts index ed6e517..3c0da35 100644 --- a/tests/dynamicToolManager.test.ts +++ b/tests/dynamicToolManager.test.ts @@ -133,4 +133,146 @@ describe("DynamicToolManager", () => { expect(res.results.length).toBe(2); expect(res.success).toBe(true); }); + + it("registers tools with annotations when provided", async () => { + const { server, tools } = createFakeMcpServer(); + const catalogWithAnnotations = { + annotated: { + name: "Annotated Tools", + description: "Tools with annotations", + tools: [ + { + name: "read_data", + description: "Read-only tool", + inputSchema: {}, + handler: async () => ({ data: "test" }), + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + { + name: "delete_data", + description: "Destructive tool", + inputSchema: {}, + handler: async () => ({ deleted: true }), + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + { + name: "fetch_weather", + description: "External API tool", + inputSchema: {}, + handler: async () => ({ weather: "sunny" }), + annotations: { + readOnlyHint: true, + openWorldHint: true, + idempotentHint: false, + }, + }, + ], + }, + } as any; + + const resolver = new ModuleResolver({ catalog: catalogWithAnnotations }); + const manager = new DynamicToolManager({ + server, + resolver, + toolRegistry: new ToolRegistry({ namespaceWithToolset: true }), + }); + + const res = await manager.enableToolset("annotated"); + expect(res.success).toBe(true); + + // Verify all tools were registered with correct annotations + expect(tools.length).toBe(3); + + const readTool = tools.find((t) => t.name === "annotated.read_data"); + expect(readTool).toBeDefined(); + expect(readTool?.annotations).toEqual({ + readOnlyHint: true, + idempotentHint: true, + }); + + const deleteTool = tools.find((t) => t.name === "annotated.delete_data"); + expect(deleteTool).toBeDefined(); + expect(deleteTool?.annotations).toEqual({ + destructiveHint: true, + idempotentHint: false, + }); + + const fetchTool = tools.find((t) => t.name === "annotated.fetch_weather"); + expect(fetchTool).toBeDefined(); + expect(fetchTool?.annotations).toEqual({ + readOnlyHint: true, + openWorldHint: true, + idempotentHint: false, + }); + }); + + it("registers tools without annotations when not provided", async () => { + const { server, tools } = createFakeMcpServer(); + const resolver = new ModuleResolver({ catalog }); + const manager = new DynamicToolManager({ + server, + resolver, + toolRegistry: new ToolRegistry({ namespaceWithToolset: true }), + }); + + const res = await manager.enableToolset("core"); + expect(res.success).toBe(true); + + const tool = tools.find((t) => t.name === "core.ping"); + expect(tool).toBeDefined(); + expect(tool?.annotations).toBeUndefined(); + }); + + it("registers module-loaded tools with annotations", async () => { + const { server, tools } = createFakeMcpServer(); + const catalogWithModules = { + external: { + name: "External", + description: "External tools", + modules: ["external"], + }, + } as any; + + const resolver = new ModuleResolver({ + catalog: catalogWithModules, + moduleLoaders: { + external: async () => [ + { + name: "api_call", + description: "Call external API", + inputSchema: {}, + handler: async () => ({ result: "ok" }), + annotations: { + openWorldHint: true, + readOnlyHint: false, + idempotentHint: false, + }, + }, + ], + }, + }); + + const manager = new DynamicToolManager({ + server, + resolver, + toolRegistry: new ToolRegistry({ namespaceWithToolset: true }), + }); + + const res = await manager.enableToolset("external"); + expect(res.success).toBe(true); + + const tool = tools.find((t) => t.name === "external.api_call"); + expect(tool).toBeDefined(); + expect(tool?.annotations).toEqual({ + openWorldHint: true, + readOnlyHint: false, + idempotentHint: false, + }); + }); }); diff --git a/tests/helpers/fakes.ts b/tests/helpers/fakes.ts index 8ce13b6..aa135cc 100644 --- a/tests/helpers/fakes.ts +++ b/tests/helpers/fakes.ts @@ -2,14 +2,31 @@ export type RegisteredTool = { name: string; description: string; schema: any; + annotations?: { + title?: string; + destructiveHint?: boolean; + idempotentHint?: boolean; + readOnlyHint?: boolean; + openWorldHint?: boolean; + }; handler: (args: any) => Promise | any; }; export function createFakeMcpServer(options: { withNotifier?: boolean } = {}) { const tools: RegisteredTool[] = []; const server: any = { - tool(name: string, description: string, schema: any, handler: any) { - tools.push({ name, description, schema, handler }); + tool( + name: string, + description: string, + schema: any, + annotationsOrHandler: any, + maybeHandler?: any + ) { + // Support both 4-param (legacy) and 5-param (with annotations) signatures + const isLegacy = typeof annotationsOrHandler === "function"; + const annotations = isLegacy ? undefined : annotationsOrHandler; + const handler = isLegacy ? annotationsOrHandler : maybeHandler; + tools.push({ name, description, schema, annotations, handler }); }, }; if (options.withNotifier) {