diff --git a/README.md b/README.md index 954912f..88b91e5 100644 --- a/README.md +++ b/README.md @@ -461,8 +461,9 @@ if (await client.isServerHealthy("time")) { Generate (cached) callable functions that work directly with AI agent frameworks. No conversion layers needed. -> [!IMPORTANT] -> If you want to get agent tools mutiple times using different filter options, you need to call `client.clearAgentToolsCache()` to force regeneration. +> [!IMPORTANT] +> Generated functions are cached for performance. Once cached, subsequent calls return cached functions regardless of filter parameters. +> To force regeneration, either call `client.clearAgentToolsCache()` first, or use the `refreshCache: true` option. ##### Supports filtering by servers and by tools @@ -474,8 +475,8 @@ This prevents overwhelming the model's context window and improves response qual ##### Examples ```typescript -// Options: { servers?: string[], tools?: string[], format?: 'array' | 'object' | 'map' } -// Default format is 'array' (for LangChain) +// Options: { servers?: string[], tools?: string[], format?: 'array' | 'object' | 'map', refreshCache?: boolean } +// Default format is 'array' (for LangChain), refreshCache defaults to false ``` LangChain @@ -553,9 +554,6 @@ const toolsObject = await client.getAgentTools({ format: "object", }); -// Clear cached generated functions before recreating -client.clearAgentToolsCache(); - // Use with Map for efficient lookups const toolMap = await client.getAgentTools({ format: "map" }); const timeTool = toolMap.get("time__get_current_time"); @@ -563,8 +561,8 @@ if (timeTool) { const result = await timeTool({ timezone: "UTC" }); } -// Clear cached generated functions before recreating -client.clearAgentToolsCache(); +// Force refresh from cache to get latest schemas +const freshTools = await client.getAgentTools({ refreshCache: true }); // Each function has metadata for both frameworks const tools = await client.getAgentTools(); diff --git a/examples/langchain/package-lock.json b/examples/langchain/package-lock.json index d9c0746..d81281a 100644 --- a/examples/langchain/package-lock.json +++ b/examples/langchain/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { + "@langchain/core": "^1.0.6", + "@langchain/openai": "^1.1.2", "@mozilla-ai/mcpd": "file:../.." }, "devDependencies": { @@ -59,6 +61,12 @@ "node": ">=22.10.0" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -354,6 +362,46 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@langchain/core": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.0.6.tgz", + "integrity": "sha512-rDSjXATujCdJlL+OJFfyZhEca8kLmqGr4W2ebJvSHiUgXEDqu/IOWC+ZWgoKKHkGOGFdVTqQ7Qi0j2RnYS9Qlg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": "^0.3.64", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^10.0.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/openai": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.1.2.tgz", + "integrity": "sha512-o642toyaRfx7Cej10jK6eK561gkIGTCQrN42fqAU9OhmTBkUflmRNKhqbcHj/RU+NOJfFM//hgwNU2gHespEkw==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^6.9.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0" + } + }, "node_modules/@mozilla-ai/mcpd": { "resolved": "../..", "link": true @@ -449,6 +497,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.47.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", @@ -739,6 +799,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -760,6 +832,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -793,11 +885,22 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -814,7 +917,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -830,7 +932,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -843,7 +944,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -853,6 +953,15 @@ "dev": true, "license": "MIT" }, + "node_modules/console-table-printer": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", + "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", + "license": "MIT", + "dependencies": { + "simple-wcswidth": "^1.1.2" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -893,6 +1002,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1151,6 +1269,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1313,7 +1437,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1407,6 +1530,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -1451,6 +1583,41 @@ "json-buffer": "3.0.1" } }, + "node_modules/langsmith": { + "version": "0.3.80", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.80.tgz", + "integrity": "sha512-BWpbB9/Hkx06S5X4nJE3W5Wm1mH/j6SIqWcM/WAuT+yulohE9knstIJGmBpmSBULb46nCj+cfjRkyF1Nrc4UmA==", + "license": "MIT", + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "p-retry": "4", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1542,6 +1709,15 @@ "dev": true, "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1549,6 +1725,28 @@ "dev": true, "license": "MIT" }, + "node_modules/openai": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.9.1.tgz", + "integrity": "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1567,6 +1765,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -1599,6 +1806,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -1712,6 +1960,15 @@ "node": ">=4" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -1751,7 +2008,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1783,6 +2039,12 @@ "node": ">=8" } }, + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", + "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -1800,7 +2062,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -1948,6 +2209,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -2003,6 +2277,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/src/client.ts b/src/client.ts index b783f79..d50539b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -29,6 +29,9 @@ import { HealthResponse, ErrorModel, AgentToolsOptions, + ArrayAgentToolsOptions, + ObjectAgentToolsOptions, + MapAgentToolsOptions, Prompt, Prompts, GeneratePromptResponseBody, @@ -71,6 +74,24 @@ const SERVER_HEALTH_CACHE_MAXSIZE = 100; */ const TOOL_SEPARATOR = "__"; +/** + * Type alias for agent functions in array format. + * @internal + */ +type AgentFunctionsArray = AgentFunction[]; + +/** + * Type alias for agent functions in object format (keyed by function name). + * @internal + */ +type AgentFunctionsRecord = Record; + +/** + * Type alias for agent functions in Map format (keyed by function name). + * @internal + */ +type AgentFunctionsMap = Map; + /** * Client for interacting with MCP (Model Context Protocol) servers through an mcpd daemon. * @@ -787,9 +808,7 @@ export class McpdClient { } throw new ToolExecutionError( - `Failed to execute tool '${toolName}' on server '${serverName}': ${ - (error as Error).message - }`, + `Failed to execute tool '${toolName}' on server '${serverName}': ${(error as Error).message}`, serverName, toolName, undefined, @@ -815,21 +834,19 @@ export class McpdClient { } /** - * Generate callable functions for use with AI agent frameworks. + * Fetch and cache callable functions from all healthy servers. * - * This method queries servers and creates self-contained, callable functions + * This method queries all healthy servers and creates self-contained, callable functions * that can be passed to AI agent frameworks. Each function includes its schema * as metadata and handles the MCP communication internally. * - * This method automatically filters out unhealthy servers by checking their health status before fetching tools. - * Unhealthy servers are skipped (with optional warnings when logging is enabled) to ensure the - * method returns quickly without waiting for timeouts on failed servers. + * Unhealthy servers are automatically filtered out and skipped (with optional warnings + * when logging is enabled) to ensure the method returns quickly without waiting for timeouts. * * Tool fetches from multiple servers are executed concurrently for optimal performance. + * Functions are cached indefinitely until explicitly cleared. * - * @param servers - Optional list of server names to include. If not specified, includes all servers. - * - * @returns Array of callable functions with metadata. Only includes tools from healthy servers. + * @returns Array of callable functions with metadata from all healthy servers. * * @throws {AuthenticationError} If API key was present and authentication fails * @throws {ConnectionError} If unable to connect to the mcpd daemon @@ -838,11 +855,17 @@ export class McpdClient { * * @internal */ - async #agentTools(servers?: string[]): Promise { - // Get healthy servers (fetches list if not provided, then filters by health). - const healthyServers = await this.#getHealthyServers(servers); + async #agentTools(): Promise { + // Return cached functions if available. + const cachedFunctions = this.#functionBuilder.getCachedFunctions(); + if (cachedFunctions.length > 0) { + return cachedFunctions; + } + + // Get all healthy servers. + const healthyServers = await this.#getHealthyServers(); - // Fetch tools from all healthy servers in parallel + // Fetch tools from all healthy servers in parallel. const results = await Promise.allSettled( healthyServers.map(async (serverName) => ({ serverName, @@ -851,7 +874,6 @@ export class McpdClient { ); // Build functions from tool schemas. - // Silently skip failed servers - they're already filtered by health checks. const agentTools: AgentFunction[] = results .filter((result) => result.status === "fulfilled") .flatMap((result) => { @@ -881,11 +903,12 @@ export class McpdClient { * * Tool fetches from multiple servers are executed concurrently for optimal performance. * - * The result of function agent tools is cached on first call for performance. - * Use {@link clearAgentToolsCache()} to force regeneration. - * For example, if servers or tools have changed, or you want to generate functions for a different set of servers/tools. + * Generated functions are cached for performance. Once cached, subsequent calls return + * the cached functions immediately without refetching schemas, regardless of filter parameters. + * Use {@link clearAgentToolsCache()} to clear the cache, or set refreshCache to true + * to force regeneration when tool schemas have changed. * - * @param options - Options for generating agent tools (format, and server/tool filtering) + * @param options - Options for output format, server/tool filtering, and cache control * * @returns Functions in the requested format (array, object, or map). * Only includes tools from healthy servers. @@ -904,6 +927,9 @@ export class McpdClient { * // Get tools from specific servers * const tools = await client.getAgentTools({ servers: ['time', 'fetch'] }); * + * // Force refresh from cache + * const freshTools = await client.getAgentTools({ refreshCache: true }); + * * // Use with LangChain JS (array format) * const langchainTools = await client.getAgentTools({ format: 'array' }); * const agent = await createOpenAIToolsAgent({ llm, tools: langchainTools, prompt }); @@ -916,47 +942,45 @@ export class McpdClient { * const result = await generateText({ model, tools: vercelTools, prompt }); * ``` */ - async getAgentTools(options?: { - format?: "array"; - servers?: string[]; - tools?: string[]; - }): Promise; - async getAgentTools(options: { - format: "object"; - servers?: string[]; - tools?: string[]; - }): Promise>; - async getAgentTools(options: { - format: "map"; - servers?: string[]; - tools?: string[]; - }): Promise>; + async getAgentTools( + options?: ArrayAgentToolsOptions, + ): Promise; + async getAgentTools( + options: ObjectAgentToolsOptions, + ): Promise>; + async getAgentTools( + options: MapAgentToolsOptions, + ): Promise>; async getAgentTools( options: AgentToolsOptions = {}, ): Promise< AgentFunction[] | Record | Map > { - const { servers, tools, format = "array" } = options; - - const allTools = await this.#agentTools(servers); - - const filteredTools = tools - ? allTools.filter((tool) => this.#matchesToolFilter(tool, tools)) - : allTools; - - switch (format) { - case "object": - return Object.fromEntries( - filteredTools.map((tool) => [tool.name, tool]), - ); - - case "map": - return new Map(filteredTools.map((tool) => [tool.name, tool])); + const { servers, tools, format = "array", refreshCache = false } = options; + + // Clear cache and fetch fresh if requested. + if (refreshCache) this.#functionBuilder.clearCache(); + + // Fetch or retrieve cached functions from all healthy servers. + const allTools = await this.#agentTools(); + + // Filter results based on servers and tools parameters. + const filteredTools = allTools + .filter((tool) => !servers || servers.includes(tool._serverName)) + .filter((tool) => !tools || this.#matchesToolFilter(tool, tools)); + + // Format output as requested. + const formatters: { + array: (t: AgentFunctionsArray) => AgentFunctionsArray; + object: (t: AgentFunctionsArray) => AgentFunctionsRecord; + map: (t: AgentFunctionsArray) => AgentFunctionsMap; + } = { + array: (t) => t, + object: (t) => Object.fromEntries(t.map((tool) => [tool.name, tool])), + map: (t) => new Map(t.map((tool) => [tool.name, tool])), + }; - case "array": - default: - return filteredTools; - } + return formatters[format](filteredTools); } /** diff --git a/src/functionBuilder.ts b/src/functionBuilder.ts index 46449b1..4cab7fc 100644 --- a/src/functionBuilder.ts +++ b/src/functionBuilder.ts @@ -404,4 +404,13 @@ export class FunctionBuilder { getCacheSize(): number { return this.#functionCache.size; } + + /** + * Get all cached functions. + * + * @returns Array of all cached agent functions, or empty array if cache is empty + */ + getCachedFunctions(): AgentFunction[] { + return Array.from(this.#functionCache.values()); + } } diff --git a/src/index.ts b/src/index.ts index f9602e6..db90be6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,11 @@ export { type McpdClientOptions, type ErrorDetail, type ErrorModel, - type ToolFormat, + type AgentToolsFormat, + type BaseAgentToolsOptions, + type ArrayAgentToolsOptions, + type ObjectAgentToolsOptions, + type MapAgentToolsOptions, type AgentToolsOptions, type Resource, type Resources, diff --git a/src/types.ts b/src/types.ts index 652a76a..626cc37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -272,37 +272,80 @@ export interface McpdClientOptions { /** * Tool format types for cross-framework compatibility. + * + * @remarks + * Output format for agent tools. + * - 'array': Returns array of functions (default, for LangChain) + * - 'object': Returns object keyed by tool name (for Vercel AI SDK) + * - 'map': Returns Map keyed by tool name */ -export type ToolFormat = "array" | "object" | "map"; +export type AgentToolsFormat = "array" | "object" | "map"; /** - * Options for generating agent tools. + * Base options shared across all agent tools configurations. */ -export interface AgentToolsOptions { +export interface BaseAgentToolsOptions { /** - * Optional list of server names to include. If not specified, includes all servers. + * List of server names to include. If not specified, or empty, should include all servers. */ servers?: string[]; /** - * Optional list of tool names to filter by. Supports both: + * List of tool names to filter by. + * + * @remarks + * Supports both: * - Raw tool names: 'get_current_time' (matches tool across all servers) * - Server-prefixed names: 'time__get_current_time' (server + TOOL_SEPARATOR + tool) * If not specified, returns all tools from selected servers. + * * @example ['add', 'multiply'] * @example ['time__get_current_time', 'math__add'] */ tools?: string[]; /** - * Output format for the tools. - * - 'array': Returns array of functions (default, for LangChain) - * - 'object': Returns object keyed by tool name (for Vercel AI SDK) - * - 'map': Returns Map keyed by tool name + * When true, clears the agent tools cache and fetches fresh tool schemas from servers. + * When false or undefined, returns cached functions if available. + * + * @defaultValue false */ - format?: ToolFormat; + refreshCache?: boolean; } +/** + * Options for getAgentTools with array format (default). + * Returns an array of agent functions. + */ +export interface ArrayAgentToolsOptions extends BaseAgentToolsOptions { + format?: "array"; +} + +/** + * Options for getAgentTools with object format. + * Returns an object keyed by tool name. + */ +export interface ObjectAgentToolsOptions extends BaseAgentToolsOptions { + format: "object"; +} + +/** + * Options for getAgentTools with map format. + * Returns a Map keyed by tool name. + */ +export interface MapAgentToolsOptions extends BaseAgentToolsOptions { + format: "map"; +} + +/** + * Options for generating agent tools. + * Discriminated union based on the format field. + */ +export type AgentToolsOptions = + | ArrayAgentToolsOptions + | ObjectAgentToolsOptions + | MapAgentToolsOptions; + /** * Function signature for performing tool calls. * This is injected into proxy classes via dependency injection. diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 961332d..7dd57a0 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -504,37 +504,27 @@ describe("McpdClient", () => { }); it("should combine server and tool filtering", async () => { - // When servers are explicitly provided, listServers() is not called. - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "time", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "math", - status: "ok", - latency: "1ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], + vi.stubGlobal( + "fetch", + createFetchMock({ + [API_PATHS.SERVERS]: ["time", "math"], + [API_PATHS.HEALTH_ALL]: { + servers: [ + { name: "time", status: "ok" }, + { name: "math", status: "ok" }, + ], + }, + [API_PATHS.SERVER_TOOLS("time")]: { + tools: mockTimeAndMathTools.time, + }, + [API_PATHS.SERVER_TOOLS("math")]: { + tools: mockTimeAndMathTools.math, + }, }), - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tools: mockTimeAndMathTools.time }), - }); + ); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tools: mockTimeAndMathTools.math }), + const client = new McpdClient({ + apiEndpoint: "http://localhost:8090", }); const tools = await client.getAgentTools({ @@ -834,9 +824,162 @@ describe("McpdClient", () => { }); }); + describe("getAgentTools caching behavior", () => { + const mockTimeAndMathTools = { + time: [ + { + name: "get_current_time", + description: "Get current time", + inputSchema: { + type: "object", + properties: { timezone: { type: "string" } }, + required: ["timezone"], + }, + }, + ], + math: [ + { + name: "add", + description: "Add two numbers", + inputSchema: { + type: "object", + properties: { a: { type: "number" }, b: { type: "number" } }, + required: ["a", "b"], + }, + }, + { + name: "multiply", + description: "Multiply two numbers", + inputSchema: { + type: "object", + properties: { a: { type: "number" }, b: { type: "number" } }, + required: ["a", "b"], + }, + }, + ], + }; + + it("should cache functions and reuse them on subsequent calls", async () => { + let fetchCallCount = 0; + const mockFn = vi.fn((url: string) => { + fetchCallCount++; + const mock = createFetchMock({ + [API_PATHS.SERVERS]: ["time", "math"], + [API_PATHS.HEALTH_ALL]: { + servers: [ + { name: "time", status: "ok" }, + { name: "math", status: "ok" }, + ], + }, + [API_PATHS.SERVER_TOOLS("time")]: { + tools: mockTimeAndMathTools.time, + }, + [API_PATHS.SERVER_TOOLS("math")]: { + tools: mockTimeAndMathTools.math, + }, + }); + return mock(url); + }); + + vi.stubGlobal("fetch", mockFn); + + const client = new McpdClient({ + apiEndpoint: "http://localhost:8090", + }); + + const firstCall = await client.getAgentTools(); + expect(firstCall).toHaveLength(3); + const firstCallCount = fetchCallCount; + + const secondCall = await client.getAgentTools(); + expect(secondCall).toHaveLength(3); + expect(fetchCallCount).toBe(firstCallCount); + }); + + it("should return filtered results from cache without refetching", async () => { + vi.stubGlobal( + "fetch", + createFetchMock({ + [API_PATHS.SERVERS]: ["time", "math"], + [API_PATHS.HEALTH_ALL]: { + servers: [ + { name: "time", status: "ok" }, + { name: "math", status: "ok" }, + ], + }, + [API_PATHS.SERVER_TOOLS("time")]: { + tools: mockTimeAndMathTools.time, + }, + [API_PATHS.SERVER_TOOLS("math")]: { + tools: mockTimeAndMathTools.math, + }, + }), + ); + + const client = new McpdClient({ + apiEndpoint: "http://localhost:8090", + }); + + const allTools = await client.getAgentTools(); + expect(allTools).toHaveLength(3); + + const timeTools = await client.getAgentTools({ servers: ["time"] }); + expect(timeTools).toHaveLength(1); + expect(timeTools[0]?._serverName).toBe("time"); + + const addTool = await client.getAgentTools({ tools: ["add"] }); + expect(addTool).toHaveLength(1); + expect(addTool[0]?._toolName).toBe("add"); + + const mathAdd = await client.getAgentTools({ + servers: ["math"], + tools: ["add"], + }); + expect(mathAdd).toHaveLength(1); + expect(mathAdd[0]?._serverName).toBe("math"); + expect(mathAdd[0]?._toolName).toBe("add"); + }); + + it("should refetch when refreshCache is true", async () => { + let callCount = 0; + const mockFn = vi.fn((url: string) => { + callCount++; + const mock = createFetchMock({ + [API_PATHS.SERVERS]: ["time", "math"], + [API_PATHS.HEALTH_ALL]: { + servers: [ + { name: "time", status: "ok" }, + { name: "math", status: "ok" }, + ], + }, + [API_PATHS.SERVER_TOOLS("time")]: { + tools: mockTimeAndMathTools.time, + }, + [API_PATHS.SERVER_TOOLS("math")]: { + tools: mockTimeAndMathTools.math, + }, + }); + return mock(url); + }); + + vi.stubGlobal("fetch", mockFn); + + const client = new McpdClient({ + apiEndpoint: "http://localhost:8090", + }); + + await client.getAgentTools(); + const firstCallCount = callCount; + + await client.getAgentTools({ refreshCache: true }); + const secondCallCount = callCount; + + expect(secondCallCount).toBeGreaterThan(firstCallCount); + }); + }); + describe("clearAgentToolsCache()", () => { it("should clear the function builder cache", () => { - // This is more of an integration test to ensure the method exists and calls through expect(() => client.clearAgentToolsCache()).not.toThrow(); }); }); @@ -978,6 +1121,7 @@ describe("McpdClient", () => { vi.stubGlobal( "fetch", createFetchMock({ + [API_PATHS.SERVERS]: ["server1", "nonexistent"], [API_PATHS.HEALTH_ALL]: { servers: [ { name: "server1", status: "ok" },