Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.5.4",
"version": "0.5.5",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
33 changes: 25 additions & 8 deletions src/core/DynamicToolManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof this.server.tool>[2],
async (args: Record<string, unknown>) => {
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<typeof this.server.tool>[2],
tool.annotations,
async (args: Record<string, unknown>) => {
return await tool.handler(args);
}
);
} else {
// Legacy 4-parameter call when no annotations
this.server.tool(
tool.name,
tool.description,
tool.inputSchema as Parameters<typeof this.server.tool>[2],
async (args: Record<string, unknown>) => {
return await tool.handler(args);
}
);
}
this.toolRegistry.addForToolset(toolsetKey, tool.name);
}

Expand Down
16 changes: 16 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ export type McpToolDefinition = {
description: string;
inputSchema: Record<string, any>;
handler: (args: any) => Promise<any> | 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 = {
Expand Down
142 changes: 142 additions & 0 deletions tests/dynamicToolManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
21 changes: 19 additions & 2 deletions tests/helpers/fakes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | 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) {
Expand Down