From 291baf39f52316f3c3d48f50d485c057f0bcefde Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Fri, 3 Apr 2026 13:53:50 -0300 Subject: [PATCH 1/6] first commit --- .../content/docs/core-concepts/tools.mdx | 22 ++ .../docs/guides/dynamic-tool-discovery.mdx | 189 ++++++++++++++++++ apps/website/content/docs/guides/meta.json | 2 +- examples/dynamic-tool-discovery/.gitignore | 3 + examples/dynamic-tool-discovery/package.json | 19 ++ .../src/tools/admin-panel.ts | 28 +++ .../src/tools/advanced-analytics.ts | 29 +++ .../src/tools/experimental.ts | 20 ++ .../src/tools/public-info.ts | 16 ++ .../src/tools/user-profile.ts | 27 +++ examples/dynamic-tool-discovery/tsconfig.json | 11 + .../dynamic-tool-discovery/xmcp.config.ts | 14 ++ .../compiler/config/__tests__/config.test.ts | 57 ++++++ packages/xmcp/src/compiler/config/index.ts | 3 + .../xmcp/src/compiler/config/schemas/index.ts | 1 + .../xmcp/src/compiler/config/schemas/tools.ts | 13 ++ .../xmcp/src/compiler/generate-import-code.ts | 45 ++++- .../get-injected-variables.ts | 9 + .../src/runtime/adapters/express/index.ts | 2 +- .../runtime/adapters/nestjs/xmcp.service.ts | 2 +- .../nextjs/handler/server-lifecycle.ts | 24 ++- .../xmcp/src/runtime/adapters/nextjs/index.ts | 2 +- .../runtime/platforms/cloudflare/worker.ts | 2 +- .../http/stateless-streamable-http.ts | 6 +- packages/xmcp/src/runtime/utils/server.ts | 24 ++- packages/xmcp/src/runtime/utils/tools.ts | 112 ++++++++++- packages/xmcp/src/types/injected.d.ts | 5 + packages/xmcp/src/types/tool.ts | 8 + 28 files changed, 673 insertions(+), 22 deletions(-) create mode 100644 apps/website/content/docs/guides/dynamic-tool-discovery.mdx create mode 100644 examples/dynamic-tool-discovery/.gitignore create mode 100644 examples/dynamic-tool-discovery/package.json create mode 100644 examples/dynamic-tool-discovery/src/tools/admin-panel.ts create mode 100644 examples/dynamic-tool-discovery/src/tools/advanced-analytics.ts create mode 100644 examples/dynamic-tool-discovery/src/tools/experimental.ts create mode 100644 examples/dynamic-tool-discovery/src/tools/public-info.ts create mode 100644 examples/dynamic-tool-discovery/src/tools/user-profile.ts create mode 100644 examples/dynamic-tool-discovery/tsconfig.json create mode 100644 examples/dynamic-tool-discovery/xmcp.config.ts create mode 100644 packages/xmcp/src/compiler/config/schemas/tools.ts diff --git a/apps/website/content/docs/core-concepts/tools.mdx b/apps/website/content/docs/core-concepts/tools.mdx index 1522c94e9..0905ebfe2 100644 --- a/apps/website/content/docs/core-concepts/tools.mdx +++ b/apps/website/content/docs/core-concepts/tools.mdx @@ -177,6 +177,28 @@ annotations: { about when and how to call your tools, but they don't enforce any behavior. +### Tool Discovery + +Control when a tool is visible to clients using discovery metadata fields: + +```typescript +export const metadata: ToolMetadata = { + name: "admin-panel", + description: "Admin operations", + enabled: false, // disabled by default (opt-in via config) + requiresAuth: true, // only visible to authenticated clients + requiredScopes: ["admin"], // requires the "admin" scope + dependsOn: ["setup-tool"], // only visible when "setup-tool" is registered +}; +``` + +- **`enabled`** — set to `false` to disable a tool by default. Operators can re-enable it via `tools.enable` or `tools.include` in `xmcp.config.ts`. +- **`requiresAuth`** — tool is hidden from unauthenticated requests. +- **`requiredScopes`** — all listed scopes must be present. Implies `requiresAuth`. +- **`dependsOn`** — tool is only visible when all named tools are also registered. + +For a full guide on configuring tool visibility, see [Dynamic Tool Discovery](/docs/guides/dynamic-tool-discovery). + ### MCP Apps metadata diff --git a/apps/website/content/docs/guides/dynamic-tool-discovery.mdx b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx new file mode 100644 index 000000000..72eb73e78 --- /dev/null +++ b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx @@ -0,0 +1,189 @@ +--- +title: "Dynamic Tool Discovery" +metadataTitle: "Dynamic Tool Discovery | xmcp Documentation" +publishedAt: "2026-04-03" +summary: "Control which tools are visible based on authentication, scopes, dependencies, and server configuration." +description: "Dynamic tool discovery lets you filter which tools are available to clients based on authentication status, OAuth scopes, inter-tool dependencies, and server-level include/exclude configuration." +--- + +By default, xmcp registers all tools found in your tools directory. Dynamic tool discovery gives you fine-grained control over which tools are visible to each client, using three layers of filtering: + +1. **Server configuration** — include or exclude tools at build time +2. **Per-tool metadata** — require authentication, specific scopes, or mark tools as disabled +3. **Tool dependencies** — make tools visible only when their prerequisites are registered + +## Per-tool metadata + +### Disable a tool by default + +Set `enabled: false` to ship a tool that's hidden unless the server operator opts in: + +```typescript title="src/tools/experimental.ts" +import { type ToolMetadata } from "xmcp"; + +export const metadata: ToolMetadata = { + name: "experimental", + description: "An experimental feature, disabled by default.", + enabled: false, +}; + +export default async function experimental() { + return "This feature is enabled via server configuration."; +} +``` + +### Require authentication + +Set `requiresAuth: true` to hide a tool from unauthenticated clients: + +```typescript title="src/tools/user-profile.ts" +import { type ToolMetadata } from "xmcp"; + +export const metadata: ToolMetadata = { + name: "user-profile", + description: "Retrieve the authenticated user's profile.", + requiresAuth: true, +}; + +export default async function userProfile() { + return "Profile data for the authenticated user."; +} +``` + +When a client connects without authentication, this tool won't appear in the `tools/list` response. + +### Require specific scopes + +Use `requiredScopes` to restrict a tool to users with specific OAuth scopes. All listed scopes must be present: + +```typescript title="src/tools/admin-panel.ts" +import { type ToolMetadata } from "xmcp"; + +export const metadata: ToolMetadata = { + name: "admin-panel", + description: "Perform administrative actions.", + requiresAuth: true, + requiredScopes: ["admin"], +}; + +export default async function adminPanel() { + return "Admin action executed."; +} +``` + +A client with scopes `["read"]` won't see this tool. A client with scopes `["admin", "read"]` will. + + + Setting `requiredScopes` implies `requiresAuth: true` — you don't need to set both, but it's clearer to be explicit. + + +### Declare tool dependencies + +Use `dependsOn` to make a tool visible only when other tools are also registered. This creates progressive discovery: + +```typescript title="src/tools/advanced-analytics.ts" +import { type ToolMetadata } from "xmcp"; + +export const metadata: ToolMetadata = { + name: "advanced-analytics", + description: "Generate analytics reports.", + dependsOn: ["admin-panel"], +}; + +export default async function advancedAnalytics() { + return "Analytics report generated."; +} +``` + +If `admin-panel` requires the `admin` scope and the current user doesn't have it, `admin-panel` is hidden — and `advanced-analytics` is hidden too, since its dependency isn't registered. + +Dependencies are **presence-based**: the dependency is satisfied if the named tool passes all its own filters in the same request. Circular dependencies are detected and both tools are excluded. + +## Server configuration + +### Include list (whitelist) + +Only bundle and register the listed tools. All others are excluded: + +```typescript title="xmcp.config.ts" +import { XmcpConfig } from "xmcp"; + +const config: XmcpConfig = { + http: true, + tools: { + include: ["public-info", "user-profile"], + }, +}; + +export default config; +``` + +Tools in the `include` list also override `enabled: false` — this is how operators can opt in to disabled-by-default tools. + +### Exclude list (blacklist) + +Load all tools except the listed ones: + +```typescript title="xmcp.config.ts" +const config: XmcpConfig = { + http: true, + tools: { + exclude: ["debug-tool", "internal-test"], + }, +}; + +export default config; +``` + + + `include` and `exclude` cannot be used together. The server will reject this configuration with an error. + + +### Enable list + +Re-enable specific tools that have `enabled: false` in their metadata, without switching to whitelist mode: + +```typescript title="xmcp.config.ts" +const config: XmcpConfig = { + http: true, + tools: { + exclude: ["debug-tool"], + enable: ["experimental"], // overrides enabled: false + }, +}; + +export default config; +``` + +`enable` works alongside either `include` or `exclude`, or standalone. + +## How layers compose + +Filtering happens in order: + +1. **Build time** — `include`/`exclude` removes tools from the bundle entirely +2. **Runtime pass 1** — `enabled`, `requiresAuth`, and `requiredScopes` are evaluated per request +3. **Runtime pass 2** — `dependsOn` is resolved iteratively until all dependency chains settle + +A tool must survive all layers to be registered. Hidden tools return a "tool not found" error if called directly — they don't reveal their existence. + +### Example walkthrough + +Given these tools: + +| Tool | `requiresAuth` | `requiredScopes` | `dependsOn` | +| ---- | -------------- | ---------------- | ----------- | +| public-info | - | - | - | +| user-profile | `true` | - | - | +| admin-panel | `true` | `["admin"]` | - | +| advanced-analytics | - | - | `["admin-panel"]` | + +**Unauthenticated client** sees: `public-info` + +**Authenticated client (scopes: `["read"]`)** sees: `public-info`, `user-profile` + +**Authenticated client (scopes: `["admin"]`)** sees: `public-info`, `user-profile`, `admin-panel`, `advanced-analytics` + +## STDIO transport + +STDIO has no authentication mechanism. Tools with `requiresAuth: true` or `requiredScopes` are automatically hidden. Tools using only `enabled` and `dependsOn` work normally. diff --git a/apps/website/content/docs/guides/meta.json b/apps/website/content/docs/guides/meta.json index 79b9dca22..01ad46f60 100644 --- a/apps/website/content/docs/guides/meta.json +++ b/apps/website/content/docs/guides/meta.json @@ -1,6 +1,6 @@ { "title": "Guides", - "pages": ["xmcp-mcp-server", "authentication", "monetization"], + "pages": ["xmcp-mcp-server", "authentication", "monetization", "dynamic-tool-discovery"], "defaultOpen": true, "root": true } diff --git a/examples/dynamic-tool-discovery/.gitignore b/examples/dynamic-tool-discovery/.gitignore new file mode 100644 index 000000000..780700a04 --- /dev/null +++ b/examples/dynamic-tool-discovery/.gitignore @@ -0,0 +1,3 @@ +.vercel +.xmcp +xmcp-env.d.ts \ No newline at end of file diff --git a/examples/dynamic-tool-discovery/package.json b/examples/dynamic-tool-discovery/package.json new file mode 100644 index 000000000..32649d5e8 --- /dev/null +++ b/examples/dynamic-tool-discovery/package.json @@ -0,0 +1,19 @@ +{ + "name": "Dynamic Tool Discovery", + "description": "Learn how to control tool visibility with auth, scopes, dependencies, and configuration", + "keywords": [ + "http", + "tool-discovery", + "auth", + "scopes" + ], + "scripts": { + "build": "xmcp build", + "dev": "xmcp dev", + "start": "node dist/http.js" + }, + "dependencies": { + "xmcp": "workspace:*", + "zod": "^4.0.10" + } +} diff --git a/examples/dynamic-tool-discovery/src/tools/admin-panel.ts b/examples/dynamic-tool-discovery/src/tools/admin-panel.ts new file mode 100644 index 000000000..0e3d33868 --- /dev/null +++ b/examples/dynamic-tool-discovery/src/tools/admin-panel.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { type InferSchema, type ToolMetadata } from "xmcp"; + +// This tool requires the "admin" scope. It's only visible to authenticated +// users whose token includes the "admin" scope. + +export const schema = { + action: z + .enum(["list-users", "reset-cache", "view-logs"]) + .describe("The admin action to perform"), +}; + +export const metadata: ToolMetadata = { + name: "admin-panel", + description: "Perform administrative actions. Requires admin scope.", + requiresAuth: true, + requiredScopes: ["admin"], + annotations: { + title: "Admin Panel", + destructiveHint: true, + }, +}; + +export default async function adminPanel({ + action, +}: InferSchema) { + return `Admin action "${action}" executed successfully.`; +} diff --git a/examples/dynamic-tool-discovery/src/tools/advanced-analytics.ts b/examples/dynamic-tool-discovery/src/tools/advanced-analytics.ts new file mode 100644 index 000000000..a7219fd1d --- /dev/null +++ b/examples/dynamic-tool-discovery/src/tools/advanced-analytics.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { type InferSchema, type ToolMetadata } from "xmcp"; + +// This tool depends on "admin-panel". It's only visible when admin-panel +// is also registered (i.e., the user has the "admin" scope). +// This creates progressive discovery: admin tools unlock analytics tools. + +export const schema = { + report: z + .enum(["usage", "performance", "errors"]) + .describe("The type of analytics report to generate"), +}; + +export const metadata: ToolMetadata = { + name: "advanced-analytics", + description: + "Generate analytics reports. Only available when admin-panel is accessible.", + dependsOn: ["admin-panel"], + annotations: { + title: "Advanced Analytics", + readOnlyHint: true, + }, +}; + +export default async function advancedAnalytics({ + report, +}: InferSchema) { + return `Analytics report "${report}" generated.`; +} diff --git a/examples/dynamic-tool-discovery/src/tools/experimental.ts b/examples/dynamic-tool-discovery/src/tools/experimental.ts new file mode 100644 index 000000000..25108fd8d --- /dev/null +++ b/examples/dynamic-tool-discovery/src/tools/experimental.ts @@ -0,0 +1,20 @@ +import { type ToolMetadata } from "xmcp"; + +// This tool is disabled by default. It won't appear unless the server +// operator explicitly enables it via the config's "enable" or "include" list. +// +// In this example's xmcp.config.ts, it's re-enabled with: +// tools: { enable: ["experimental"] } + +export const metadata: ToolMetadata = { + name: "experimental", + description: "An experimental feature, disabled by default.", + enabled: false, + annotations: { + title: "Experimental Feature", + }, +}; + +export default async function experimental() { + return "This experimental feature is enabled via server configuration."; +} diff --git a/examples/dynamic-tool-discovery/src/tools/public-info.ts b/examples/dynamic-tool-discovery/src/tools/public-info.ts new file mode 100644 index 000000000..879ad4308 --- /dev/null +++ b/examples/dynamic-tool-discovery/src/tools/public-info.ts @@ -0,0 +1,16 @@ +import { type ToolMetadata } from "xmcp"; + +// This tool has no discovery restrictions — it's always visible to all clients. + +export const metadata: ToolMetadata = { + name: "public-info", + description: "Returns public server information. Always visible.", + annotations: { + title: "Public Info", + readOnlyHint: true, + }, +}; + +export default async function publicInfo() { + return "This is a public tool. No authentication required."; +} diff --git a/examples/dynamic-tool-discovery/src/tools/user-profile.ts b/examples/dynamic-tool-discovery/src/tools/user-profile.ts new file mode 100644 index 000000000..8bc16db64 --- /dev/null +++ b/examples/dynamic-tool-discovery/src/tools/user-profile.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { type InferSchema, type ToolMetadata } from "xmcp"; + +// This tool requires authentication. It won't appear in tool listings +// for unauthenticated clients. + +export const schema = { + field: z + .enum(["email", "name", "role"]) + .describe("The profile field to retrieve"), +}; + +export const metadata: ToolMetadata = { + name: "user-profile", + description: "Retrieve the authenticated user's profile information.", + requiresAuth: true, + annotations: { + title: "User Profile", + readOnlyHint: true, + }, +}; + +export default async function userProfile({ + field, +}: InferSchema) { + return `Profile field "${field}" for the authenticated user.`; +} diff --git a/examples/dynamic-tool-discovery/tsconfig.json b/examples/dynamic-tool-discovery/tsconfig.json new file mode 100644 index 000000000..369a04ea4 --- /dev/null +++ b/examples/dynamic-tool-discovery/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["xmcp-env.d.ts", "src/**/*.ts"] +} diff --git a/examples/dynamic-tool-discovery/xmcp.config.ts b/examples/dynamic-tool-discovery/xmcp.config.ts new file mode 100644 index 000000000..6c4730b5b --- /dev/null +++ b/examples/dynamic-tool-discovery/xmcp.config.ts @@ -0,0 +1,14 @@ +import { XmcpConfig } from "xmcp"; + +const config: XmcpConfig = { + http: true, + typescript: { + skipTypeCheck: true, + }, + tools: { + // Re-enable the experimental tool (which has enabled: false in its metadata) + enable: ["experimental"], + }, +}; + +export default config; diff --git a/packages/xmcp/src/compiler/config/__tests__/config.test.ts b/packages/xmcp/src/compiler/config/__tests__/config.test.ts index dba3273e7..2a27b336d 100644 --- a/packages/xmcp/src/compiler/config/__tests__/config.test.ts +++ b/packages/xmcp/src/compiler/config/__tests__/config.test.ts @@ -376,6 +376,63 @@ describe("Config System - Edge Cases", () => { }); }); +describe("Config System - Tools Config", () => { + it("should accept tools config with include only", () => { + const parsed = configSchema.parse({ + tools: { include: ["tool-a", "tool-b"] }, + }); + assert.deepEqual(parsed.tools?.include, ["tool-a", "tool-b"]); + assert.equal(parsed.tools?.exclude, undefined); + }); + + it("should accept tools config with exclude only", () => { + const parsed = configSchema.parse({ + tools: { exclude: ["tool-c"] }, + }); + assert.deepEqual(parsed.tools?.exclude, ["tool-c"]); + assert.equal(parsed.tools?.include, undefined); + }); + + it("should accept tools config with enable only", () => { + const parsed = configSchema.parse({ + tools: { enable: ["experimental"] }, + }); + assert.deepEqual(parsed.tools?.enable, ["experimental"]); + }); + + it("should accept tools config with exclude + enable", () => { + const parsed = configSchema.parse({ + tools: { exclude: ["debug"], enable: ["experimental"] }, + }); + assert.deepEqual(parsed.tools?.exclude, ["debug"]); + assert.deepEqual(parsed.tools?.enable, ["experimental"]); + }); + + it("should reject tools config with both include and exclude", () => { + assert.throws(() => { + configSchema.parse({ + tools: { include: ["a"], exclude: ["b"] }, + }); + }); + }); + + it("should accept empty tools config", () => { + const parsed = configSchema.parse({ tools: {} }); + assert.equal(parsed.tools?.include, undefined); + assert.equal(parsed.tools?.exclude, undefined); + }); + + it("should accept config without tools", () => { + const parsed = configSchema.parse({}); + assert.equal(parsed.tools, undefined); + }); + + it("should accept empty include array", () => { + const parsed = configSchema.parse({ tools: { include: [] } }); + assert.deepEqual(parsed.tools?.include, []); + }); +}); + describe("Config System - Backward Compatibility", () => { it("should accept boolean http config (backward compatible)", () => { const resolved = getResolvedHttpConfig(true); diff --git a/packages/xmcp/src/compiler/config/index.ts b/packages/xmcp/src/compiler/config/index.ts index 13041cef7..a69153eea 100644 --- a/packages/xmcp/src/compiler/config/index.ts +++ b/packages/xmcp/src/compiler/config/index.ts @@ -7,6 +7,7 @@ import { templateConfigSchema, typescriptConfigSchema, bundlerConfigSchema, + toolsConfigSchema, } from "./schemas"; import type { RspackOptions } from "@rspack/core"; @@ -21,6 +22,7 @@ export const configSchema = z.object({ bundler: bundlerConfigSchema.optional(), template: templateConfigSchema.optional(), typescript: typescriptConfigSchema.optional(), + tools: toolsConfigSchema.optional(), }); type BundlerConfigType = { bundler?: (config: RspackOptions) => RspackOptions }; @@ -59,4 +61,5 @@ export type { BundlerConfig, TemplateConfig, TypescriptConfig, + ToolsConfig, } from "./schemas"; diff --git a/packages/xmcp/src/compiler/config/schemas/index.ts b/packages/xmcp/src/compiler/config/schemas/index.ts index e9e15ec74..438697517 100644 --- a/packages/xmcp/src/compiler/config/schemas/index.ts +++ b/packages/xmcp/src/compiler/config/schemas/index.ts @@ -18,3 +18,4 @@ export { pathsConfigSchema, type PathsConfig, DEFAULT_PATHS } from "./paths"; export { bundlerConfigSchema, type BundlerConfig } from "./bundler"; export { templateConfigSchema, type TemplateConfig } from "./template"; export { typescriptConfigSchema, type TypescriptConfig } from "./typescript"; +export { toolsConfigSchema, type ToolsConfig } from "./tools"; diff --git a/packages/xmcp/src/compiler/config/schemas/tools.ts b/packages/xmcp/src/compiler/config/schemas/tools.ts new file mode 100644 index 000000000..fad4a82c7 --- /dev/null +++ b/packages/xmcp/src/compiler/config/schemas/tools.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v3"; + +export const toolsConfigSchema = z + .object({ + include: z.array(z.string()).optional(), + exclude: z.array(z.string()).optional(), + enable: z.array(z.string()).optional(), + }) + .refine((val) => !(val.include && val.exclude), { + message: "Cannot specify both 'include' and 'exclude' in tools config", + }); + +export type ToolsConfig = z.infer; diff --git a/packages/xmcp/src/compiler/generate-import-code.ts b/packages/xmcp/src/compiler/generate-import-code.ts index f3abcfeef..b97840bfd 100644 --- a/packages/xmcp/src/compiler/generate-import-code.ts +++ b/packages/xmcp/src/compiler/generate-import-code.ts @@ -1,4 +1,5 @@ import { compilerContext } from "./compiler-context"; +import type { ToolsConfig } from "./config"; /** * Generate a valid identifier from a file path for use in variable names. @@ -8,6 +9,43 @@ function pathToIdentifier(path: string): string { return path.replace(/[^a-zA-Z0-9]/g, "_"); } +/** Extract tool name from a file path (filename without extension) */ +function pathToToolName(path: string): string { + const fileName = path.split("/").pop() || path; + return fileName.replace(/\.[^/.]+$/, ""); +} + +/** Filter tool paths based on include/exclude config */ +function filterToolPaths( + toolPaths: Set, + toolsConfig?: ToolsConfig +): Set { + if (!toolsConfig) return toolPaths; + + if (toolsConfig.include) { + const includeSet = new Set(toolsConfig.include); + const filtered = new Set(); + for (const path of toolPaths) { + if (includeSet.has(pathToToolName(path))) filtered.add(path); + } + if (filtered.size === 0 && toolPaths.size > 0) { + console.warn("[xmcp] Warning: tools.include resulted in zero tools"); + } + return filtered; + } + + if (toolsConfig.exclude) { + const excludeSet = new Set(toolsConfig.exclude); + const filtered = new Set(); + for (const path of toolPaths) { + if (!excludeSet.has(pathToToolName(path))) filtered.add(path); + } + return filtered; + } + + return toolPaths; +} + export function generateImportCode(): string { const { toolPaths, @@ -16,15 +54,18 @@ export function generateImportCode(): string { hasMiddleware, clientBundles, platforms, + xmcpConfig, } = compilerContext.getContext(); + const filteredToolPaths = filterToolPaths(toolPaths, xmcpConfig?.tools); + const isCloudflare = platforms?.cloudflare; // For Cloudflare, use static imports to avoid code splitting. // For Node.js, use dynamic imports for lazy loading. if (isCloudflare) { return generateStaticImportCode( - toolPaths, + filteredToolPaths, promptPaths, resourcePaths, hasMiddleware, @@ -33,7 +74,7 @@ export function generateImportCode(): string { } return generateDynamicImportCode( - toolPaths, + filteredToolPaths, promptPaths, resourcePaths, hasMiddleware, diff --git a/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts b/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts index d3ce2a488..1786ed091 100644 --- a/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts +++ b/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts @@ -33,6 +33,13 @@ export function getInjectedVariables( const adapterVariables = injectAdapterVariables(xmcpConfig); const typescriptVariables = injectTypescriptVariables(xmcpConfig); + // Compute enable list for runtime (union of include + enable arrays) + const toolsConfig = xmcpConfig.tools; + const enableList = [ + ...(toolsConfig?.include ?? []), + ...(toolsConfig?.enable ?? []), + ]; + return { ...httpVariables, ...corsVariables, @@ -42,5 +49,7 @@ export function getInjectedVariables( ...serverInfoVariables, ...adapterVariables, ...typescriptVariables, + INJECTED_TOOLS_ENABLE: + enableList.length > 0 ? JSON.stringify(enableList) : "undefined", }; } diff --git a/packages/xmcp/src/runtime/adapters/express/index.ts b/packages/xmcp/src/runtime/adapters/express/index.ts index 76e63f9cf..19da5c4b6 100644 --- a/packages/xmcp/src/runtime/adapters/express/index.ts +++ b/packages/xmcp/src/runtime/adapters/express/index.ts @@ -34,7 +34,7 @@ export async function xmcpHandler(req: Request, res: Response) { req.headers.origin ); - const server = await createServer(); + const server = await createServer((req as any).auth); const transport = new StatelessHttpServerTransport( debug, bodySizeLimit || "10mb" diff --git a/packages/xmcp/src/runtime/adapters/nestjs/xmcp.service.ts b/packages/xmcp/src/runtime/adapters/nestjs/xmcp.service.ts index 5892f664a..e4d1237e9 100644 --- a/packages/xmcp/src/runtime/adapters/nestjs/xmcp.service.ts +++ b/packages/xmcp/src/runtime/adapters/nestjs/xmcp.service.ts @@ -47,7 +47,7 @@ export class XmcpService implements OnModuleInit, OnModuleDestroy { try { setHeaders(res, corsConfig, req.headers.origin); - const server = await createServer(); + const server = await createServer((req as any).auth); const transport = new StatelessHttpServerTransport( httpConfig.debug, String(httpConfig.bodySizeLimit) || "10mb" diff --git a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts index 49db18c44..5079ac1cb 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts @@ -8,6 +8,7 @@ import { loadResources, loadTools, } from "@/runtime/utils/server"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; export interface ServerLifecycle { server: McpServer; @@ -36,7 +37,9 @@ export function setupCleanupHandlers( /** * Initializes and configures the MCP server with tools, prompts, and resources */ -export async function initializeMcpServer(): Promise { +export async function initializeMcpServer( + authInfo?: AuthInfo +): Promise { const toolModulesPromise = loadTools(); const [promptPromises, promptModules] = loadPrompts(); const [resourcePromises, resourceModules] = loadResources(); @@ -50,7 +53,19 @@ export async function initializeMcpServer(): Promise { const server = new McpServer(INJECTED_CONFIG); - await configureServer(server, toolModules, promptModules, resourceModules); + const enableList = + typeof INJECTED_TOOLS_ENABLE !== "undefined" + ? INJECTED_TOOLS_ENABLE + : undefined; + + await configureServer( + server, + toolModules, + promptModules, + resourceModules, + authInfo, + enableList + ); return server; } @@ -59,9 +74,10 @@ export async function initializeMcpServer(): Promise { * Creates and connects server lifecycle components */ export async function createServerLifecycle( - bodySizeLimit: string = "10mb" + bodySizeLimit: string = "10mb", + authInfo?: AuthInfo ): Promise { - const server = await initializeMcpServer(); + const server = await initializeMcpServer(authInfo); const transport = new StatelessHttpServerTransport(false, bodySizeLimit); await server.connect(transport); diff --git a/packages/xmcp/src/runtime/adapters/nextjs/index.ts b/packages/xmcp/src/runtime/adapters/nextjs/index.ts index bf99735b2..48ecc43fa 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/index.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/index.ts @@ -26,7 +26,7 @@ export async function xmcpHandler(request: Request): Promise { return nodeToWebAdapter(request.signal, async (res: ServerResponse) => { try { // Initialize server and transport - const lifecycle = await createServerLifecycle(BODY_SIZE_LIMIT); + const lifecycle = await createServerLifecycle(BODY_SIZE_LIMIT, request.auth); // Setup cleanup handlers setupCleanupHandlers(res, lifecycle); diff --git a/packages/xmcp/src/runtime/platforms/cloudflare/worker.ts b/packages/xmcp/src/runtime/platforms/cloudflare/worker.ts index e8e28d525..15da59da9 100644 --- a/packages/xmcp/src/runtime/platforms/cloudflare/worker.ts +++ b/packages/xmcp/src/runtime/platforms/cloudflare/worker.ts @@ -136,7 +136,7 @@ async function handleMcpRequest( let transport: WebStatelessHttpTransport | null = null; try { - server = await createServer(); + server = await createServer(authInfo); transport = new WebStatelessHttpTransport(httpConfig.debug); await server.connect(transport); diff --git a/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts b/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts index 2f7fd778d..df178b44d 100644 --- a/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts +++ b/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts @@ -278,12 +278,12 @@ export class StatelessStreamableHTTPTransport { private endpoint: string; private debug: boolean; private options: HttpTransportOptions; - private createServerFn: () => Promise; + private createServerFn: (authInfo?: AuthInfo) => Promise; private corsConfig: CorsConfig; private providers: Provider[] | undefined; constructor( - createServerFn: () => Promise, + createServerFn: (authInfo?: AuthInfo) => Promise, options: HttpTransportOptions = {}, corsConfig: CorsConfig = corsConfigSchema.parse({}), providers?: Provider[] @@ -430,7 +430,7 @@ export class StatelessStreamableHTTPTransport { ): Promise { try { // Create new instances for complete isolation - const server = await this.createServerFn(); + const server = await this.createServerFn(req.auth); const transport = new StatelessHttpServerTransport( this.debug, this.options.bodySizeLimit || "10mb" diff --git a/packages/xmcp/src/runtime/utils/server.ts b/packages/xmcp/src/runtime/utils/server.ts index 8f3535596..0328b270d 100644 --- a/packages/xmcp/src/runtime/utils/server.ts +++ b/packages/xmcp/src/runtime/utils/server.ts @@ -12,6 +12,7 @@ import { addResourcesToServer } from "./resources"; import { ResourceMetadata } from "@/types/resource"; import { uIResourceRegistry } from "./ext-apps-registry"; import { loadToolModules, reportToolLoadIssues } from "./tool-loader"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; export type ToolFile = { metadata: ToolMetadata; @@ -55,11 +56,13 @@ export async function configureServer( server: McpServer, toolModules: Map, promptModules: Map, - resourceModules: Map + resourceModules: Map, + authInfo?: AuthInfo, + enableList?: string[] ): Promise { uIResourceRegistry.clear(); - addToolsToServer(server, toolModules); + addToolsToServer(server, toolModules, authInfo, enableList); addPromptsToServer(server, promptModules); addResourcesToServer(server, resourceModules); return server; @@ -95,12 +98,25 @@ export function loadResources() { return [resourcePromises, resourceModules] as const; } -export async function createServer() { +export async function createServer(authInfo?: AuthInfo) { const server = new McpServer(INJECTED_CONFIG); const toolModulesPromise = loadTools(); const [promptPromises, promptModules] = loadPrompts(); const [resourcePromises, resourceModules] = loadResources(); await Promise.all([toolModulesPromise, ...promptPromises, ...resourcePromises]); const toolModules = await toolModulesPromise; - return configureServer(server, toolModules, promptModules, resourceModules); + + const enableList = + typeof INJECTED_TOOLS_ENABLE !== "undefined" + ? INJECTED_TOOLS_ENABLE + : undefined; + + return configureServer( + server, + toolModules, + promptModules, + resourceModules, + authInfo, + enableList + ); } diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index b61049b20..127076a21 100644 --- a/packages/xmcp/src/runtime/utils/tools.ts +++ b/packages/xmcp/src/runtime/utils/tools.ts @@ -9,6 +9,7 @@ import { uIResourceRegistry } from "./ext-apps-registry"; import { flattenMeta, hasUIMeta } from "./ui/flatten-meta"; import { splitUIMetaNested } from "./ui/split-meta"; import { isPaidHandler, getX402Registry } from "@/plugins/x402"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; /** Validates if a value is a valid Zod schema object */ export function isZodRawShape(value: unknown): value is ZodRawShape { @@ -38,11 +39,93 @@ export function ensureAnnotations(toolConfig: Pick 0) && + !authInfo + ) + return false; + // requiredScopes — ALL must match + if (metadata.requiredScopes && metadata.requiredScopes.length > 0) { + if ( + !metadata.requiredScopes.every((s) => authInfo!.scopes.includes(s)) + ) + return false; + } + return true; +} + +/** Resolves tool dependencies iteratively. Returns the set of tool names that passed. */ +export function resolveDependencies( + candidates: Map, + passedFilter: Set +): Set { + const resolved = new Set(); + + // Tools with no dependencies pass immediately + for (const [name, meta] of candidates) { + if ( + passedFilter.has(name) && + (!meta.dependsOn || meta.dependsOn.length === 0) + ) { + resolved.add(name); + } + } + + // Iterative resolution for tools with dependencies + let changed = true; + const maxIterations = candidates.size; + let iteration = 0; + + while (changed && iteration < maxIterations) { + changed = false; + iteration++; + for (const [name, meta] of candidates) { + if (resolved.has(name)) continue; + if (!passedFilter.has(name)) continue; + if (meta.dependsOn?.every((dep) => resolved.has(dep))) { + resolved.add(name); + changed = true; + } + } + } + + // Warn about unresolved tools (circular deps or missing deps) + for (const [name, meta] of candidates) { + if (passedFilter.has(name) && !resolved.has(name) && meta.dependsOn?.length) { + const missing = meta.dependsOn.filter((d) => !resolved.has(d)); + console.warn( + `[xmcp] Tool "${name}" excluded: unresolved dependencies [${missing.join(", ")}]` + ); + } + } + + return resolved; +} + +/** Loads tools and injects them into the server, applying filtering */ export function addToolsToServer( server: McpServer, - toolModules: Map + toolModules: Map, + authInfo?: AuthInfo, + enableList?: string[] ): McpServer { + // --- Pass 1: Build tool configs and evaluate per-tool filters --- + const passedFilter = new Set(); + const candidates = new Map(); + const toolData = new Map< + string, + { path: string; toolModule: ToolFile; toolConfig: ToolMetadata } + >(); + toolModules.forEach((toolModule, path) => { const defaultName = pathToName(path); @@ -51,12 +134,33 @@ export function addToolsToServer( description: "No description provided", }; - const { default: handler, metadata, schema, outputSchema } = toolModule; + const { metadata } = toolModule; if (typeof metadata === "object" && metadata !== null) { Object.assign(toolConfig, metadata); } + const includedInConfig = enableList?.includes(toolConfig.name) ?? false; + + if (!shouldRegisterTool(toolConfig, authInfo, includedInConfig)) { + return; // skip this tool + } + + passedFilter.add(toolConfig.name); + candidates.set(toolConfig.name, toolConfig); + toolData.set(toolConfig.name, { path, toolModule, toolConfig }); + }); + + // --- Pass 2: Resolve dependencies --- + const resolved = resolveDependencies(candidates, passedFilter); + + // --- Pass 3: Register surviving tools --- + for (const [name, data] of toolData) { + if (!resolved.has(name)) continue; + + const { path, toolModule, toolConfig } = data; + const { default: handler, schema, outputSchema } = toolModule; + // Register paid tools in x402 registry if plugin is installed if (isPaidHandler(handler)) { const registry = getX402Registry(); @@ -177,7 +281,7 @@ export function addToolsToServer( toolConfigFormatted, transformedHandler ); - }); + } return server; } diff --git a/packages/xmcp/src/types/injected.d.ts b/packages/xmcp/src/types/injected.d.ts index 281fe0a71..b9fb5f175 100644 --- a/packages/xmcp/src/types/injected.d.ts +++ b/packages/xmcp/src/types/injected.d.ts @@ -51,6 +51,11 @@ declare const HTTP_CORS_MAX_AGE: number; declare const HTTP_DEBUG: boolean; declare const HTTP_BODY_SIZE_LIMIT: string; +// ─── DefinePlugin — tool discovery config ──────────────────────────────────── + +/** Enable list for overriding tools with enabled: false (union of config include + enable) */ +declare const INJECTED_TOOLS_ENABLE: string[] | undefined; + // ─── DefinePlugin — runtime flags ───────────────────────────────────────────── declare const IS_CLOUDFLARE: boolean; diff --git a/packages/xmcp/src/types/tool.ts b/packages/xmcp/src/types/tool.ts index 8dd8c2588..53d1ce8a1 100644 --- a/packages/xmcp/src/types/tool.ts +++ b/packages/xmcp/src/types/tool.ts @@ -28,6 +28,14 @@ export interface ToolMetadata { ui?: UIMetadata; [key: string]: unknown; }; + /** Whether this tool is enabled. Defaults to true. Set to false to disable by default. */ + enabled?: boolean; + /** If true, tool is only visible to authenticated requests. */ + requiresAuth?: boolean; + /** OAuth scopes required for this tool to be visible. Implies requiresAuth. All scopes must match. */ + requiredScopes?: string[]; + /** Tool names this tool depends on. Only visible if all dependencies are registered. */ + dependsOn?: string[]; } type CompatibleZodType = z.ZodTypeAny | ZodTypeV4; From 1b8e03a1944b68e7e7f4705224ee1e35158a1128 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Fri, 3 Apr 2026 14:39:09 -0300 Subject: [PATCH 2/6] docs update --- .../content/docs/core-concepts/tools.mdx | 8 +- .../docs/guides/dynamic-tool-discovery.mdx | 176 ++++++++++-------- 2 files changed, 105 insertions(+), 79 deletions(-) diff --git a/apps/website/content/docs/core-concepts/tools.mdx b/apps/website/content/docs/core-concepts/tools.mdx index 0905ebfe2..0257bbee7 100644 --- a/apps/website/content/docs/core-concepts/tools.mdx +++ b/apps/website/content/docs/core-concepts/tools.mdx @@ -192,10 +192,10 @@ export const metadata: ToolMetadata = { }; ``` -- **`enabled`** — set to `false` to disable a tool by default. Operators can re-enable it via `tools.enable` or `tools.include` in `xmcp.config.ts`. -- **`requiresAuth`** — tool is hidden from unauthenticated requests. -- **`requiredScopes`** — all listed scopes must be present. Implies `requiresAuth`. -- **`dependsOn`** — tool is only visible when all named tools are also registered. +- **`enabled`**: set to `false` to disable a tool by default. Operators can re-enable it via `tools.enable` or `tools.include` in `xmcp.config.ts`. +- **`requiresAuth`**: tool is hidden from unauthenticated requests. +- **`requiredScopes`**: all listed scopes must be present. Implies `requiresAuth`. +- **`dependsOn`**: tool is only visible when all named tools are also registered. For a full guide on configuring tool visibility, see [Dynamic Tool Discovery](/docs/guides/dynamic-tool-discovery). diff --git a/apps/website/content/docs/guides/dynamic-tool-discovery.mdx b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx index 72eb73e78..43408b9fe 100644 --- a/apps/website/content/docs/guides/dynamic-tool-discovery.mdx +++ b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx @@ -6,38 +6,54 @@ summary: "Control which tools are visible based on authentication, scopes, depen description: "Dynamic tool discovery lets you filter which tools are available to clients based on authentication status, OAuth scopes, inter-tool dependencies, and server-level include/exclude configuration." --- -By default, xmcp registers all tools found in your tools directory. Dynamic tool discovery gives you fine-grained control over which tools are visible to each client, using three layers of filtering: +## Overview -1. **Server configuration** — include or exclude tools at build time -2. **Per-tool metadata** — require authentication, specific scopes, or mark tools as disabled -3. **Tool dependencies** — make tools visible only when their prerequisites are registered +By default, xmcp registers all tools found in your tools directory and makes them available to every client. Every tool is enabled unless explicitly configured otherwise. -## Per-tool metadata +Dynamic tool discovery adds control over which tools are visible per request. This is useful when your server exposes tools with different access levels, when certain tools should only appear after authentication, or when you want to keep deployments lightweight by loading only what you need. -### Disable a tool by default +Filtering happens in three layers, evaluated in order: -Set `enabled: false` to ship a tool that's hidden unless the server operator opts in: +1. **Build time**: the `tools` config in `xmcp.config.ts` removes tools from the bundle entirely +2. **Runtime pass 1**: per-tool metadata (`enabled`, `requiresAuth`, `requiredScopes`) is evaluated against the current request +3. **Runtime pass 2**: `dependsOn` declarations are resolved iteratively until all dependency chains settle -```typescript title="src/tools/experimental.ts" -import { type ToolMetadata } from "xmcp"; +A tool must survive all three layers to be registered. If a client calls a hidden tool directly, the server returns a standard "tool not found" error without revealing that the tool exists. -export const metadata: ToolMetadata = { - name: "experimental", - description: "An experimental feature, disabled by default.", - enabled: false, -}; +### Example -export default async function experimental() { - return "This feature is enabled via server configuration."; -} -``` +Consider a server with four tools: + +| Tool | `requiresAuth` | `requiredScopes` | `dependsOn` | +| ---- | -------------- | ---------------- | ----------- | +| public-info | - | - | - | +| user-profile | `true` | - | - | +| admin-panel | `true` | `["admin"]` | - | +| advanced-analytics | - | - | `["admin-panel"]` | + +An **unauthenticated client** sees only `public-info`. + +An **authenticated client with scopes `["read"]`** sees `public-info` and `user-profile`. The `admin-panel` tool requires the `admin` scope, so it stays hidden. Because `admin-panel` is hidden, `advanced-analytics` is also hidden since its dependency is not registered. + +An **authenticated client with scopes `["admin"]`** sees all four tools. The `admin-panel` passes its scope check, which satisfies the dependency for `advanced-analytics`. + +## Per-tool metadata + +All tools are enabled by default. The following optional metadata fields let tool authors declare visibility requirements directly on their tools. ### Require authentication -Set `requiresAuth: true` to hide a tool from unauthenticated clients: +Set `requiresAuth: true` to hide a tool from unauthenticated clients. The tool only appears in `tools/list` when the request includes a valid access token. ```typescript title="src/tools/user-profile.ts" -import { type ToolMetadata } from "xmcp"; +import { z } from "zod"; +import { type InferSchema, type ToolMetadata } from "xmcp"; + +export const schema = { + field: z + .enum(["email", "name", "role"]) + .describe("The profile field to retrieve"), +}; export const metadata: ToolMetadata = { name: "user-profile", @@ -45,65 +61,104 @@ export const metadata: ToolMetadata = { requiresAuth: true, }; -export default async function userProfile() { - return "Profile data for the authenticated user."; +export default async function userProfile({ + field, +}: InferSchema) { + return `Profile field "${field}" for the authenticated user.`; } ``` -When a client connects without authentication, this tool won't appear in the `tools/list` response. - ### Require specific scopes -Use `requiredScopes` to restrict a tool to users with specific OAuth scopes. All listed scopes must be present: +Use `requiredScopes` to restrict a tool to users whose access token includes specific OAuth scopes. All listed scopes must be present for the tool to appear. ```typescript title="src/tools/admin-panel.ts" -import { type ToolMetadata } from "xmcp"; +import { z } from "zod"; +import { type InferSchema, type ToolMetadata } from "xmcp"; + +export const schema = { + action: z + .enum(["list-users", "reset-cache", "view-logs"]) + .describe("The admin action to perform"), +}; export const metadata: ToolMetadata = { name: "admin-panel", - description: "Perform administrative actions.", - requiresAuth: true, + description: "Perform administrative actions. Requires admin scope.", requiredScopes: ["admin"], }; -export default async function adminPanel() { - return "Admin action executed."; +export default async function adminPanel({ + action, +}: InferSchema) { + return `Admin action "${action}" executed successfully.`; } ``` A client with scopes `["read"]` won't see this tool. A client with scopes `["admin", "read"]` will. - Setting `requiredScopes` implies `requiresAuth: true` — you don't need to set both, but it's clearer to be explicit. + Setting `requiredScopes` implies authentication is required. You can omit `requiresAuth` when scopes are declared, but including it makes the intent clearer. ### Declare tool dependencies -Use `dependsOn` to make a tool visible only when other tools are also registered. This creates progressive discovery: +Use `dependsOn` to create progressive discovery, where a tool only appears when its prerequisites are also registered. Dependencies are presence-based: the check passes if the named tool survives all its own filters in the same request. + +This is particularly useful when combined with scoped tools. In the following example, `advanced-analytics` becomes visible only when the user has the `admin` scope, because its dependency `admin-panel` requires that scope. ```typescript title="src/tools/advanced-analytics.ts" -import { type ToolMetadata } from "xmcp"; +import { z } from "zod"; +import { type InferSchema, type ToolMetadata } from "xmcp"; + +export const schema = { + report: z + .enum(["usage", "performance", "errors"]) + .describe("The type of analytics report to generate"), +}; export const metadata: ToolMetadata = { name: "advanced-analytics", - description: "Generate analytics reports.", + description: "Generate analytics reports. Available when admin-panel is accessible.", dependsOn: ["admin-panel"], }; -export default async function advancedAnalytics() { - return "Analytics report generated."; +export default async function advancedAnalytics({ + report, +}: InferSchema) { + return `Analytics report "${report}" generated.`; } ``` -If `admin-panel` requires the `admin` scope and the current user doesn't have it, `admin-panel` is hidden — and `advanced-analytics` is hidden too, since its dependency isn't registered. +If the user does not have the `admin` scope, `admin-panel` is hidden. Because `admin-panel` is hidden, `advanced-analytics` is hidden too. When the user does have the scope, both tools appear. + +Circular dependencies are detected automatically. If tool A depends on tool B and tool B depends on tool A, both are excluded and a warning is logged. + +### Disable a tool by default + +Set `enabled: false` to ship a tool that is hidden unless the server operator opts in through the `enable` or `include` configuration. + +```typescript title="src/tools/experimental.ts" +import { type ToolMetadata } from "xmcp"; + +export const metadata: ToolMetadata = { + name: "experimental", + description: "An experimental feature, disabled by default.", + enabled: false, +}; -Dependencies are **presence-based**: the dependency is satisfied if the named tool passes all its own filters in the same request. Circular dependencies are detected and both tools are excluded. +export default async function experimental() { + return "This feature is enabled via server configuration."; +} +``` ## Server configuration -### Include list (whitelist) +The `tools` key in `xmcp.config.ts` controls which tools are bundled and loaded by the server. These options filter tools at build time, before any runtime checks. -Only bundle and register the listed tools. All others are excluded: +### Include list + +Only bundle and register the listed tools. All other tools are excluded from the build. ```typescript title="xmcp.config.ts" import { XmcpConfig } from "xmcp"; @@ -118,11 +173,11 @@ const config: XmcpConfig = { export default config; ``` -Tools in the `include` list also override `enabled: false` — this is how operators can opt in to disabled-by-default tools. +Tools in the `include` list also override `enabled: false`. This is how operators can opt in to disabled-by-default tools. -### Exclude list (blacklist) +### Exclude list -Load all tools except the listed ones: +Load all tools except the listed ones. ```typescript title="xmcp.config.ts" const config: XmcpConfig = { @@ -141,49 +196,20 @@ export default config; ### Enable list -Re-enable specific tools that have `enabled: false` in their metadata, without switching to whitelist mode: +Re-enable specific tools that have `enabled: false` in their metadata without switching to whitelist mode. This works alongside `exclude` or on its own. ```typescript title="xmcp.config.ts" const config: XmcpConfig = { http: true, tools: { exclude: ["debug-tool"], - enable: ["experimental"], // overrides enabled: false + enable: ["experimental"], }, }; export default config; ``` -`enable` works alongside either `include` or `exclude`, or standalone. - -## How layers compose - -Filtering happens in order: - -1. **Build time** — `include`/`exclude` removes tools from the bundle entirely -2. **Runtime pass 1** — `enabled`, `requiresAuth`, and `requiredScopes` are evaluated per request -3. **Runtime pass 2** — `dependsOn` is resolved iteratively until all dependency chains settle - -A tool must survive all layers to be registered. Hidden tools return a "tool not found" error if called directly — they don't reveal their existence. - -### Example walkthrough - -Given these tools: - -| Tool | `requiresAuth` | `requiredScopes` | `dependsOn` | -| ---- | -------------- | ---------------- | ----------- | -| public-info | - | - | - | -| user-profile | `true` | - | - | -| admin-panel | `true` | `["admin"]` | - | -| advanced-analytics | - | - | `["admin-panel"]` | - -**Unauthenticated client** sees: `public-info` - -**Authenticated client (scopes: `["read"]`)** sees: `public-info`, `user-profile` - -**Authenticated client (scopes: `["admin"]`)** sees: `public-info`, `user-profile`, `admin-panel`, `advanced-analytics` - ## STDIO transport -STDIO has no authentication mechanism. Tools with `requiresAuth: true` or `requiredScopes` are automatically hidden. Tools using only `enabled` and `dependsOn` work normally. +STDIO runs locally on the user's machine and has no authentication mechanism. Tools with `requiresAuth` or `requiredScopes` are automatically hidden since no auth context exists. Tools using `enabled` and `dependsOn` work normally. From 075faef0188bc7641b4466997f0170516fd23c10 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Fri, 3 Apr 2026 14:49:50 -0300 Subject: [PATCH 3/6] fix possible bugs --- .../get-bundler-config/get-injected-variables.ts | 12 ++++++++++++ .../adapters/nextjs/handler/server-lifecycle.ts | 6 +----- packages/xmcp/src/runtime/adapters/nextjs/index.ts | 2 +- packages/xmcp/src/runtime/utils/server.ts | 10 +++++----- packages/xmcp/src/runtime/utils/tools.ts | 12 +++++++++++- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts b/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts index 1786ed091..56f688194 100644 --- a/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts +++ b/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts @@ -40,6 +40,18 @@ export function getInjectedVariables( ...(toolsConfig?.enable ?? []), ]; + // Warn if a tool is in both exclude and enable (exclude wins at build time) + if (toolsConfig?.exclude && toolsConfig?.enable) { + const excludeSet = new Set(toolsConfig.exclude); + for (const name of toolsConfig.enable) { + if (excludeSet.has(name)) { + console.warn( + `[xmcp] Warning: tool "${name}" is in both 'exclude' and 'enable'. It will be excluded at build time and the enable override will have no effect.` + ); + } + } + } + return { ...httpVariables, ...corsVariables, diff --git a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts index 5079ac1cb..238a73ec2 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts @@ -4,6 +4,7 @@ import { StatelessHttpServerTransport } from "@/runtime/transports/http/stateles import { configureServer, INJECTED_CONFIG, + enableList, loadPrompts, loadResources, loadTools, @@ -53,11 +54,6 @@ export async function initializeMcpServer( const server = new McpServer(INJECTED_CONFIG); - const enableList = - typeof INJECTED_TOOLS_ENABLE !== "undefined" - ? INJECTED_TOOLS_ENABLE - : undefined; - await configureServer( server, toolModules, diff --git a/packages/xmcp/src/runtime/adapters/nextjs/index.ts b/packages/xmcp/src/runtime/adapters/nextjs/index.ts index 48ecc43fa..7ce835927 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/index.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/index.ts @@ -26,7 +26,7 @@ export async function xmcpHandler(request: Request): Promise { return nodeToWebAdapter(request.signal, async (res: ServerResponse) => { try { // Initialize server and transport - const lifecycle = await createServerLifecycle(BODY_SIZE_LIMIT, request.auth); + const lifecycle = await createServerLifecycle(BODY_SIZE_LIMIT, (request as any).auth); // Setup cleanup handlers setupCleanupHandlers(res, lifecycle); diff --git a/packages/xmcp/src/runtime/utils/server.ts b/packages/xmcp/src/runtime/utils/server.ts index 0328b270d..8c17e2a78 100644 --- a/packages/xmcp/src/runtime/utils/server.ts +++ b/packages/xmcp/src/runtime/utils/server.ts @@ -98,6 +98,11 @@ export function loadResources() { return [resourcePromises, resourceModules] as const; } +export const enableList: string[] | undefined = + typeof INJECTED_TOOLS_ENABLE !== "undefined" + ? INJECTED_TOOLS_ENABLE + : undefined; + export async function createServer(authInfo?: AuthInfo) { const server = new McpServer(INJECTED_CONFIG); const toolModulesPromise = loadTools(); @@ -106,11 +111,6 @@ export async function createServer(authInfo?: AuthInfo) { await Promise.all([toolModulesPromise, ...promptPromises, ...resourcePromises]); const toolModules = await toolModulesPromise; - const enableList = - typeof INJECTED_TOOLS_ENABLE !== "undefined" - ? INJECTED_TOOLS_ENABLE - : undefined; - return configureServer( server, toolModules, diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index 127076a21..1a5ea84e3 100644 --- a/packages/xmcp/src/runtime/utils/tools.ts +++ b/packages/xmcp/src/runtime/utils/tools.ts @@ -56,7 +56,9 @@ export function shouldRegisterTool( // requiredScopes — ALL must match if (metadata.requiredScopes && metadata.requiredScopes.length > 0) { if ( - !metadata.requiredScopes.every((s) => authInfo!.scopes.includes(s)) + !metadata.requiredScopes.every((s) => + (authInfo!.scopes ?? []).includes(s) + ) ) return false; } @@ -146,6 +148,14 @@ export function addToolsToServer( return; // skip this tool } + // Warn on duplicate tool names + const existing = toolData.get(toolConfig.name); + if (existing) { + console.warn( + `[xmcp] Warning: duplicate tool name "${toolConfig.name}" (files: "${existing.path}", "${path}"). The last file wins.` + ); + } + passedFilter.add(toolConfig.name); candidates.set(toolConfig.name, toolConfig); toolData.set(toolConfig.name, { path, toolModule, toolConfig }); From 9086ea795eb51e36b034b5d1f2edb4874f979457 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Fri, 3 Apr 2026 16:56:12 -0300 Subject: [PATCH 4/6] Bug fixes --- .../content/docs/core-concepts/tools.mdx | 2 + .../docs/guides/dynamic-tool-discovery.mdx | 24 ++++++ .../xmcp/src/compiler/generate-import-code.ts | 23 ++--- .../xmcp/src/compiler/generate-tools-code.ts | 37 +++----- packages/xmcp/src/compiler/index.ts | 9 +- packages/xmcp/src/compiler/tool-discovery.ts | 86 +++++++++++++++++++ .../http/stateless-streamable-http.ts | 2 +- packages/xmcp/src/runtime/utils/tools.ts | 4 +- 8 files changed, 143 insertions(+), 44 deletions(-) create mode 100644 packages/xmcp/src/compiler/tool-discovery.ts diff --git a/apps/website/content/docs/core-concepts/tools.mdx b/apps/website/content/docs/core-concepts/tools.mdx index 0257bbee7..8750b30db 100644 --- a/apps/website/content/docs/core-concepts/tools.mdx +++ b/apps/website/content/docs/core-concepts/tools.mdx @@ -197,6 +197,8 @@ export const metadata: ToolMetadata = { - **`requiredScopes`**: all listed scopes must be present. Implies `requiresAuth`. - **`dependsOn`**: tool is only visible when all named tools are also registered. +Discovery config matches the tool's public name, meaning `metadata.name` when present. Tool names must also be unique across the project; duplicate names are treated as an error. + For a full guide on configuring tool visibility, see [Dynamic Tool Discovery](/docs/guides/dynamic-tool-discovery). ### MCP Apps metadata diff --git a/apps/website/content/docs/guides/dynamic-tool-discovery.mdx b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx index 43408b9fe..e3cfafc61 100644 --- a/apps/website/content/docs/guides/dynamic-tool-discovery.mdx +++ b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx @@ -20,6 +20,8 @@ Filtering happens in three layers, evaluated in order: A tool must survive all three layers to be registered. If a client calls a hidden tool directly, the server returns a standard "tool not found" error without revealing that the tool exists. +Tool names in `tools.include`, `tools.exclude`, and `tools.enable` always refer to the tool's public name: `metadata.name` when present, otherwise the filename fallback. If a tool file is named `internal-name.ts` but exports `metadata.name = "public-name"`, config must use `"public-name"`. + ### Example Consider a server with four tools: @@ -175,6 +177,10 @@ export default config; Tools in the `include` list also override `enabled: false`. This is how operators can opt in to disabled-by-default tools. + + `include`, `exclude`, and `enable` match the tool's public name from `metadata.name`, not the source filename. + + ### Exclude list Load all tools except the listed ones. @@ -210,6 +216,24 @@ const config: XmcpConfig = { export default config; ``` +### Duplicate names + +Each tool must have a unique public name. If two files resolve to the same tool name, xmcp fails with an error instead of picking one silently. + +```typescript title="src/tools/internal-name.ts" +export const metadata = { + name: "public-name", +}; +``` + +```typescript title="xmcp.config.ts" +const config = { + tools: { + include: ["public-name"], + }, +}; +``` + ## STDIO transport STDIO runs locally on the user's machine and has no authentication mechanism. Tools with `requiresAuth` or `requiredScopes` are automatically hidden since no auth context exists. Tools using `enabled` and `dependsOn` work normally. diff --git a/packages/xmcp/src/compiler/generate-import-code.ts b/packages/xmcp/src/compiler/generate-import-code.ts index b97840bfd..3be106cb4 100644 --- a/packages/xmcp/src/compiler/generate-import-code.ts +++ b/packages/xmcp/src/compiler/generate-import-code.ts @@ -1,5 +1,6 @@ import { compilerContext } from "./compiler-context"; import type { ToolsConfig } from "./config"; +import type { ResolvedToolEntry } from "./tool-discovery"; /** * Generate a valid identifier from a file path for use in variable names. @@ -9,24 +10,19 @@ function pathToIdentifier(path: string): string { return path.replace(/[^a-zA-Z0-9]/g, "_"); } -/** Extract tool name from a file path (filename without extension) */ -function pathToToolName(path: string): string { - const fileName = path.split("/").pop() || path; - return fileName.replace(/\.[^/.]+$/, ""); -} - /** Filter tool paths based on include/exclude config */ function filterToolPaths( - toolPaths: Set, + toolEntries: ResolvedToolEntry[], toolsConfig?: ToolsConfig ): Set { + const toolPaths = new Set(toolEntries.map((entry) => entry.path)); if (!toolsConfig) return toolPaths; if (toolsConfig.include) { const includeSet = new Set(toolsConfig.include); const filtered = new Set(); - for (const path of toolPaths) { - if (includeSet.has(pathToToolName(path))) filtered.add(path); + for (const entry of toolEntries) { + if (includeSet.has(entry.canonicalName)) filtered.add(entry.path); } if (filtered.size === 0 && toolPaths.size > 0) { console.warn("[xmcp] Warning: tools.include resulted in zero tools"); @@ -37,8 +33,8 @@ function filterToolPaths( if (toolsConfig.exclude) { const excludeSet = new Set(toolsConfig.exclude); const filtered = new Set(); - for (const path of toolPaths) { - if (!excludeSet.has(pathToToolName(path))) filtered.add(path); + for (const entry of toolEntries) { + if (!excludeSet.has(entry.canonicalName)) filtered.add(entry.path); } return filtered; } @@ -46,9 +42,8 @@ function filterToolPaths( return toolPaths; } -export function generateImportCode(): string { +export function generateImportCode(toolEntries: ResolvedToolEntry[]): string { const { - toolPaths, promptPaths, resourcePaths, hasMiddleware, @@ -57,7 +52,7 @@ export function generateImportCode(): string { xmcpConfig, } = compilerContext.getContext(); - const filteredToolPaths = filterToolPaths(toolPaths, xmcpConfig?.tools); + const filteredToolPaths = filterToolPaths(toolEntries, xmcpConfig?.tools); const isCloudflare = platforms?.cloudflare; diff --git a/packages/xmcp/src/compiler/generate-tools-code.ts b/packages/xmcp/src/compiler/generate-tools-code.ts index 0ba9df6ac..93ae0e8a1 100644 --- a/packages/xmcp/src/compiler/generate-tools-code.ts +++ b/packages/xmcp/src/compiler/generate-tools-code.ts @@ -1,29 +1,24 @@ -import { compilerContext } from "./compiler-context"; +import type { ResolvedToolEntry } from "./tool-discovery"; // this is an experimental feature // currently it's only used for the nextjs adapter & is manually generated to resolve the async imports // this prevents the tools from being an async function that needs to resolve first, instead it just returns the array -export function generateToolsExportCode(): string { - const { toolPaths } = compilerContext.getContext(); - - const importStatements = Array.from(toolPaths) - .map((p, index) => { - const path = p.replace(/\\/g, "/"); - const relativePath = `../${path}`; +export function generateToolsExportCode(toolEntries: ResolvedToolEntry[]): string { + const importStatements = toolEntries + .map(({ path }, index) => { + const normalizedPath = path.replace(/\\/g, "/"); + const relativePath = `../${normalizedPath}`; return `import * as tool${index} from "${relativePath}";`; }) .join("\n"); - const toolsArray = Array.from(toolPaths) - .map((p, index) => { - const path = p.replace(/\\/g, "/"); - const fileName = path.split("/").pop() || path; - const defaultName = fileName.replace(/\.[^/.]+$/, ""); - + const toolsArray = toolEntries + .map(({ path, canonicalName }, index) => { + const normalizedPath = path.replace(/\\/g, "/"); return `{ - path: "${path}", - name: "${defaultName}", + path: "${normalizedPath}", + name: "${canonicalName}", module: tool${index} }`; }) @@ -115,14 +110,8 @@ export async function getTools() { export const tools = await getTools();`; } -export function generateToolsTypesCode(): string { - const { toolPaths } = compilerContext.getContext(); - - const toolNames = Array.from(toolPaths).map((p) => { - const path = p.replace(/\\/g, "/"); - const fileName = path.split("/").pop() || path; - return fileName.replace(/\.[^/.]+$/, ""); - }); +export function generateToolsTypesCode(toolEntries: ResolvedToolEntry[]): string { + const toolNames = toolEntries.map((entry) => entry.canonicalName); const toolNamesUnion = toolNames.length > 0 diff --git a/packages/xmcp/src/compiler/index.ts b/packages/xmcp/src/compiler/index.ts index 750991bfa..7c2874ef2 100644 --- a/packages/xmcp/src/compiler/index.ts +++ b/packages/xmcp/src/compiler/index.ts @@ -7,6 +7,7 @@ import { generateToolsExportCode, generateToolsTypesCode, } from "./generate-tools-code"; +import { resolveToolEntries } from "./tool-discovery"; import fs from "fs"; import { rootFolder, runtimeFolderPath } from "@/utils/constants"; import { createFolder } from "@/utils/fs-utils"; @@ -433,12 +434,14 @@ async function buildClientBundles(): Promise | undefined> { async function generateCode() { // Build client bundles first (if there are React components) const clientBundles = await buildClientBundles(); + const { toolPaths } = compilerContext.getContext(); + const toolEntries = await resolveToolEntries(toolPaths); // Store in context for import map generation compilerContext.setContext({ clientBundles }); // Generate import map code (includes client bundles) - const fileContent = generateImportCode(); + const fileContent = generateImportCode(toolEntries); writeFileIfChanged(path.join(runtimeFolderPath, "import-map.js"), fileContent); // Generate runtime exports for global access @@ -449,9 +452,9 @@ async function generateCode() { // only generating tools files for nextjs adapter mode const { xmcpConfig } = compilerContext.getContext(); if (xmcpConfig?.experimental?.adapter === "nextjs") { - const toolsCode = generateToolsExportCode(); + const toolsCode = generateToolsExportCode(toolEntries); writeFileIfChanged(path.join(runtimeFolderPath, "tools.js"), toolsCode); - const typesCode = generateToolsTypesCode(); + const typesCode = generateToolsTypesCode(toolEntries); writeFileIfChanged(path.join(runtimeFolderPath, "tools.d.ts"), typesCode); } } diff --git a/packages/xmcp/src/compiler/tool-discovery.ts b/packages/xmcp/src/compiler/tool-discovery.ts new file mode 100644 index 000000000..ddaba028c --- /dev/null +++ b/packages/xmcp/src/compiler/tool-discovery.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ToolMetadata } from "@/types/tool"; + +export type ResolvedToolEntry = { + path: string; + defaultName: string; + canonicalName: string; +}; + +function defaultToolNameFromPath(toolPath: string): string { + const fileName = toolPath.split("/").pop() || toolPath; + return fileName.replace(/\.[^/.]+$/, ""); +} + +function resolveCanonicalToolName( + metadata: unknown, + defaultName: string +): string { + if (typeof metadata !== "object" || metadata === null) { + return defaultName; + } + + const { name } = metadata as Partial; + return typeof name === "string" && name.length > 0 ? name : defaultName; +} + +async function importToolModule(toolPath: string): Promise { + const absolutePath = path.resolve(process.cwd(), toolPath); + const mtimeMs = fs.statSync(absolutePath).mtimeMs; + const fileUrl = pathToFileURL(absolutePath); + fileUrl.searchParams.set("xmcp-tool-mtime", String(mtimeMs)); + return import(/* webpackIgnore: true */ fileUrl.href); +} + +function assertNoDuplicateToolNames(entries: ResolvedToolEntry[]): void { + const seen = new Map(); + + for (const entry of entries) { + const existing = seen.get(entry.canonicalName); + if (!existing) { + seen.set(entry.canonicalName, entry); + continue; + } + + throw new Error( + `[xmcp] Duplicate tool name "${entry.canonicalName}" found in "${existing.path}" and "${entry.path}". Rename one tool or remove one file.` + ); + } +} + +export async function resolveToolEntries( + toolPaths: Set +): Promise { + const entries = await Promise.all( + Array.from(toolPaths) + .sort() + .map(async (toolPath) => { + const defaultName = defaultToolNameFromPath(toolPath); + + try { + const toolModule = await importToolModule(toolPath); + const metadata = + typeof toolModule === "object" && toolModule !== null + ? (toolModule as { metadata?: unknown }).metadata + : undefined; + + return { + path: toolPath, + defaultName, + canonicalName: resolveCanonicalToolName(metadata, defaultName), + }; + } catch (error) { + throw new Error( + `[xmcp] Failed to resolve tool metadata for "${toolPath}". ` + + `Tool discovery needs a canonical name at build time.\n` + + `Original error: ${error instanceof Error ? error.message : String(error)}` + ); + } + }) + ); + + assertNoDuplicateToolNames(entries); + return entries; +} diff --git a/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts b/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts index df178b44d..35b21ac99 100644 --- a/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts +++ b/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts @@ -425,7 +425,7 @@ export class StatelessStreamableHTTPTransport { } private async handleStatelessRequest( - req: Request, + req: Request & { auth?: AuthInfo }, res: Response ): Promise { try { diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index 1a5ea84e3..d0bc0d9bd 100644 --- a/packages/xmcp/src/runtime/utils/tools.ts +++ b/packages/xmcp/src/runtime/utils/tools.ts @@ -151,8 +151,8 @@ export function addToolsToServer( // Warn on duplicate tool names const existing = toolData.get(toolConfig.name); if (existing) { - console.warn( - `[xmcp] Warning: duplicate tool name "${toolConfig.name}" (files: "${existing.path}", "${path}"). The last file wins.` + throw new Error( + `[xmcp] Duplicate tool name "${toolConfig.name}" found in "${existing.path}" and "${path}". Rename one tool or remove one file.` ); } From ab8146cdb80fc39dd0bc4fde75c4740dcd6485c4 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Fri, 3 Apr 2026 16:58:49 -0300 Subject: [PATCH 5/6] Update pnpm-lock.yaml --- pnpm-lock.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 203805dba..ab2032f84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -467,6 +467,15 @@ importers: specifier: ^4.0.10 version: 4.1.13 + examples/dynamic-tool-discovery: + dependencies: + xmcp: + specifier: workspace:* + version: link:../../packages/xmcp + zod: + specifier: ^4.0.10 + version: 4.1.13 + examples/ext-apps: dependencies: react: From c15742e40ac3d57795e0ae8609813f3881d9cd70 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Thu, 16 Apr 2026 17:38:34 -0300 Subject: [PATCH 6/6] update ux to avoid silent excludes, typos silent, stdio silently dropped auth tools and sec updates --- .../docs/guides/dynamic-tool-discovery.mdx | 46 +++- .../src/tools/multi-hop-report.ts | 33 +++ .../src/tools/multi-scope-export.ts | 27 ++ .../dynamic-tool-discovery/xmcp.config.ts | 14 + packages/xmcp/package.json | 4 +- .../xmcp/src/compiler/generate-import-code.ts | 27 ++ packages/xmcp/src/compiler/tool-discovery.ts | 85 ++++++ .../src/runtime/utils/__tests__/tools.test.ts | 243 ++++++++++++++++++ packages/xmcp/src/runtime/utils/debug.ts | 6 + packages/xmcp/src/runtime/utils/server.ts | 102 +++++++- packages/xmcp/src/runtime/utils/tools.ts | 105 +++++--- 11 files changed, 642 insertions(+), 50 deletions(-) create mode 100644 examples/dynamic-tool-discovery/src/tools/multi-hop-report.ts create mode 100644 examples/dynamic-tool-discovery/src/tools/multi-scope-export.ts create mode 100644 packages/xmcp/src/runtime/utils/__tests__/tools.test.ts create mode 100644 packages/xmcp/src/runtime/utils/debug.ts diff --git a/apps/website/content/docs/guides/dynamic-tool-discovery.mdx b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx index e3cfafc61..3065dfd88 100644 --- a/apps/website/content/docs/guides/dynamic-tool-discovery.mdx +++ b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx @@ -177,6 +177,10 @@ export default config; Tools in the `include` list also override `enabled: false`. This is how operators can opt in to disabled-by-default tools. + + `include` implies `enable`. Listing a tool in `include` activates it even if its metadata sets `enabled: false` — you don't need to also add it to `enable`. `include` is both an allowlist and a default-on override. + + `include`, `exclude`, and `enable` match the tool's public name from `metadata.name`, not the source filename. @@ -234,6 +238,44 @@ const config = { }; ``` -## STDIO transport +## Auth on STDIO + +STDIO runs locally on the user's machine and has no per-request authentication mechanism. Tools with `requiresAuth: true` or `requiredScopes` are automatically hidden since no auth context exists. Tools using `enabled` and `dependsOn` work normally. + +When xmcp boots a transport without `authInfo` and auth-gated tools exist, it prints a one-time startup warning so misconfigured HTTP servers don't silently hide tools. The message shows a count, not the tool names — names are only printed when `XMCP_DEBUG=1` is set. + +If you need auth-gated tools, run xmcp over HTTP and provide `authInfo` from your transport's middleware (Express, NestJS, Next.js, Cloudflare Workers all support this). + +## Debugging hidden tools + +For security, xmcp never names filtered tools in regular logs — that would let an operator enumerate hidden tool identities from stderr. In development you can opt in to verbose diagnostics by setting the `XMCP_DEBUG` environment variable: + +```bash +XMCP_DEBUG=1 pnpm dev +``` + +This enables: + +- Names of tools skipped because of unresolved `dependsOn` targets. +- Names of auth-gated tools skipped when a transport provides no `authInfo`. +- Details on runtime duplicate-name collisions (build-time duplicate detection already covers this case with full file paths; the runtime check is defense-in-depth). + +`XMCP_DEBUG` is off by default and should stay off in production. + +## Configuration validation + +xmcp validates tool references and the dependency graph at build time so problems surface before your server ships. + +### Warnings + +- **Unknown tool names in `enable` / `include` / `exclude`** — xmcp prints a warning listing the unknown name alongside the names it actually discovered. This catches typos and stale entries after a file rename. +- **Empty result from `include` or `exclude`** — if your filter removes every tool, xmcp warns rather than silently shipping a zero-tool bundle. +- **Non-string `requiredScopes` entries** — ignored at runtime, flagged at build time. + +### Build errors + +The following stop the build and must be fixed: -STDIO runs locally on the user's machine and has no authentication mechanism. Tools with `requiresAuth` or `requiredScopes` are automatically hidden since no auth context exists. Tools using `enabled` and `dependsOn` work normally. +- **Duplicate tool names** — two files resolving to the same `metadata.name`. +- **Missing `dependsOn` targets** — a tool declares a dependency on a name that does not exist. +- **Cycles in `dependsOn`** — xmcp reports the cycle participants (e.g., `a → b → a`). diff --git a/examples/dynamic-tool-discovery/src/tools/multi-hop-report.ts b/examples/dynamic-tool-discovery/src/tools/multi-hop-report.ts new file mode 100644 index 000000000..7f96f281d --- /dev/null +++ b/examples/dynamic-tool-discovery/src/tools/multi-hop-report.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { type InferSchema, type ToolMetadata } from "xmcp"; + +// Three-level dependency chain: +// multi-hop-report → advanced-analytics → admin-panel +// The tool is only visible when the entire chain resolves, which in this +// example means the caller must have the "admin" scope. + +export const schema = { + periodDays: z + .number() + .int() + .positive() + .max(365) + .describe("Reporting window, in days (1–365)"), +}; + +export const metadata: ToolMetadata = { + name: "multi-hop-report", + description: + "Aggregated report that requires advanced-analytics (which in turn requires admin-panel).", + dependsOn: ["advanced-analytics"], + annotations: { + title: "Multi-Hop Report", + readOnlyHint: true, + }, +}; + +export default async function multiHopReport({ + periodDays, +}: InferSchema) { + return `Report for the last ${periodDays} day(s) generated via the admin → analytics → report chain.`; +} diff --git a/examples/dynamic-tool-discovery/src/tools/multi-scope-export.ts b/examples/dynamic-tool-discovery/src/tools/multi-scope-export.ts new file mode 100644 index 000000000..b56ae0eaa --- /dev/null +++ b/examples/dynamic-tool-discovery/src/tools/multi-scope-export.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { type InferSchema, type ToolMetadata } from "xmcp"; + +// Multi-scope gating. The caller must hold BOTH "read:users" and +// "write:reports" scopes for this tool to appear in tool listings. + +export const schema = { + format: z + .enum(["csv", "json", "pdf"]) + .describe("Export file format"), +}; + +export const metadata: ToolMetadata = { + name: "multi-scope-export", + description: + "Export user data to a report file. Requires read:users AND write:reports scopes.", + requiredScopes: ["read:users", "write:reports"], + annotations: { + title: "Multi-Scope Export", + }, +}; + +export default async function multiScopeExport({ + format, +}: InferSchema) { + return `User export prepared in ${format.toUpperCase()} format.`; +} diff --git a/examples/dynamic-tool-discovery/xmcp.config.ts b/examples/dynamic-tool-discovery/xmcp.config.ts index 6c4730b5b..c23c37679 100644 --- a/examples/dynamic-tool-discovery/xmcp.config.ts +++ b/examples/dynamic-tool-discovery/xmcp.config.ts @@ -5,9 +5,23 @@ const config: XmcpConfig = { typescript: { skipTypeCheck: true, }, + paths: { + prompts: false, + resources: false, + }, tools: { // Re-enable the experimental tool (which has enabled: false in its metadata) enable: ["experimental"], + + // --- Try these alternative modes by swapping them in: --- + // + // Allowlist mode — only bundle the listed tools. + // Listing a tool in `include` implicitly re-enables it even if its + // metadata has `enabled: false`. + // include: ["public-info", "user-profile", "multi-hop-report"], + // + // Denylist mode — bundle everything except the listed tools. + // exclude: ["experimental"], }, }; diff --git a/packages/xmcp/package.json b/packages/xmcp/package.json index 55f0a4dc6..02d109fdf 100644 --- a/packages/xmcp/package.json +++ b/packages/xmcp/package.json @@ -73,7 +73,9 @@ "dev": "cross-env NODE_ENV=development tsx --watch --tsconfig ./bundler.tsconfig.json ./bundler/index.ts", "build": "cross-env NODE_ENV=production tsx --tsconfig ./bundler.tsconfig.json ./bundler/index.ts", "analyze": "cross-env GENERATE_STATS=true pnpm build && pnpm tsx scripts/analyze-rspack-stats.ts", - "test:config": "node --import tsx --test src/compiler/config/__tests__/*.test.ts" + "test:config": "node --import tsx --test src/compiler/config/__tests__/*.test.ts", + "test:runtime": "node --import tsx --test src/runtime/utils/__tests__/*.test.ts", + "test": "pnpm test:config && pnpm test:runtime" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/packages/xmcp/src/compiler/generate-import-code.ts b/packages/xmcp/src/compiler/generate-import-code.ts index 3be106cb4..f05a6e190 100644 --- a/packages/xmcp/src/compiler/generate-import-code.ts +++ b/packages/xmcp/src/compiler/generate-import-code.ts @@ -10,6 +10,28 @@ function pathToIdentifier(path: string): string { return path.replace(/[^a-zA-Z0-9]/g, "_"); } +/** Warn on unknown tool names referenced by include/exclude/enable. */ +function warnOnUnknownToolNames( + toolEntries: ResolvedToolEntry[], + toolsConfig: ToolsConfig +): void { + const knownNames = new Set(toolEntries.map((e) => e.canonicalName)); + const fields = ["enable", "include", "exclude"] as const; + for (const field of fields) { + const names = toolsConfig[field]; + if (!names) continue; + for (const name of names) { + if (!knownNames.has(name)) { + const known = + knownNames.size > 0 ? [...knownNames].join(", ") : "(none)"; + console.warn( + `[xmcp] Warning: tools.${field} references unknown tool "${name}". Known tools: ${known}` + ); + } + } + } +} + /** Filter tool paths based on include/exclude config */ function filterToolPaths( toolEntries: ResolvedToolEntry[], @@ -18,6 +40,8 @@ function filterToolPaths( const toolPaths = new Set(toolEntries.map((entry) => entry.path)); if (!toolsConfig) return toolPaths; + warnOnUnknownToolNames(toolEntries, toolsConfig); + if (toolsConfig.include) { const includeSet = new Set(toolsConfig.include); const filtered = new Set(); @@ -36,6 +60,9 @@ function filterToolPaths( for (const entry of toolEntries) { if (!excludeSet.has(entry.canonicalName)) filtered.add(entry.path); } + if (filtered.size === 0 && toolPaths.size > 0) { + console.warn("[xmcp] Warning: tools.exclude removed every tool"); + } return filtered; } diff --git a/packages/xmcp/src/compiler/tool-discovery.ts b/packages/xmcp/src/compiler/tool-discovery.ts index ddaba028c..d62bdfd4f 100644 --- a/packages/xmcp/src/compiler/tool-discovery.ts +++ b/packages/xmcp/src/compiler/tool-discovery.ts @@ -3,10 +3,19 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import type { ToolMetadata } from "@/types/tool"; +/** Auth/scope/dep data extracted at build time for validation and plumbing. */ +export type ToolMetadataSnapshot = { + dependsOn?: string[]; + requiresAuth?: boolean; + requiredScopes?: string[]; + enabled?: boolean; +}; + export type ResolvedToolEntry = { path: string; defaultName: string; canonicalName: string; + metadataSnapshot: ToolMetadataSnapshot; }; function defaultToolNameFromPath(toolPath: string): string { @@ -26,6 +35,20 @@ function resolveCanonicalToolName( return typeof name === "string" && name.length > 0 ? name : defaultName; } +function extractMetadataSnapshot(metadata: unknown): ToolMetadataSnapshot { + const snapshot: ToolMetadataSnapshot = {}; + if (typeof metadata !== "object" || metadata === null) return snapshot; + + const m = metadata as Partial; + if (Array.isArray(m.dependsOn)) snapshot.dependsOn = m.dependsOn as string[]; + if (typeof m.requiresAuth === "boolean") + snapshot.requiresAuth = m.requiresAuth; + if (Array.isArray(m.requiredScopes)) + snapshot.requiredScopes = m.requiredScopes as string[]; + if (typeof m.enabled === "boolean") snapshot.enabled = m.enabled; + return snapshot; +} + async function importToolModule(toolPath: string): Promise { const absolutePath = path.resolve(process.cwd(), toolPath); const mtimeMs = fs.statSync(absolutePath).mtimeMs; @@ -50,6 +73,66 @@ function assertNoDuplicateToolNames(entries: ResolvedToolEntry[]): void { } } +/** + * Validate the dependsOn graph and scope types at build time. Missing targets + * and cycles are hard errors; non-string scopes are warnings since they fall + * back gracefully at runtime. + */ +function validateToolMetadata(entries: ResolvedToolEntry[]): void { + const byName = new Map(); + for (const e of entries) byName.set(e.canonicalName, e); + + for (const e of entries) { + const deps = e.metadataSnapshot.dependsOn ?? []; + for (const dep of deps) { + if (!byName.has(dep)) { + throw new Error( + `[xmcp] Tool "${e.canonicalName}" depends on unknown tool "${dep}". Check the dependsOn array in "${e.path}".` + ); + } + } + + const scopes = e.metadataSnapshot.requiredScopes ?? []; + for (const s of scopes) { + if (typeof s !== "string") { + console.warn( + `[xmcp] Tool "${e.canonicalName}" has a non-string entry in requiredScopes (${JSON.stringify(s)}) in "${e.path}". Will be ignored at runtime.` + ); + } + } + } + + // DFS cycle detection with white/gray/black coloring. + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + for (const e of entries) color.set(e.canonicalName, WHITE); + + const visit = (name: string, stack: string[]): void => { + color.set(name, GRAY); + stack.push(name); + const deps = byName.get(name)?.metadataSnapshot.dependsOn ?? []; + for (const dep of deps) { + const c = color.get(dep); + if (c === GRAY) { + const idx = stack.indexOf(dep); + const cycle = stack.slice(idx).concat(dep).join(" → "); + throw new Error(`[xmcp] Dependency cycle: ${cycle}`); + } + if (c === WHITE) visit(dep, stack); + } + stack.pop(); + color.set(name, BLACK); + }; + + for (const e of entries) { + if (color.get(e.canonicalName) === WHITE) { + visit(e.canonicalName, []); + } + } +} + export async function resolveToolEntries( toolPaths: Set ): Promise { @@ -70,6 +153,7 @@ export async function resolveToolEntries( path: toolPath, defaultName, canonicalName: resolveCanonicalToolName(metadata, defaultName), + metadataSnapshot: extractMetadataSnapshot(metadata), }; } catch (error) { throw new Error( @@ -82,5 +166,6 @@ export async function resolveToolEntries( ); assertNoDuplicateToolNames(entries); + validateToolMetadata(entries); return entries; } diff --git a/packages/xmcp/src/runtime/utils/__tests__/tools.test.ts b/packages/xmcp/src/runtime/utils/__tests__/tools.test.ts new file mode 100644 index 000000000..cf04c37bd --- /dev/null +++ b/packages/xmcp/src/runtime/utils/__tests__/tools.test.ts @@ -0,0 +1,243 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { shouldRegisterTool, resolveDependencies } from "../tools"; +import type { ToolMetadata } from "../../../types/tool"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; + +const baseMeta = (overrides: Partial = {}): ToolMetadata => ({ + name: overrides.name ?? "tool", + description: overrides.description ?? "test", + ...overrides, +}); + +const auth = (scopes: string[] = []): AuthInfo => + ({ token: "t", clientId: "c", scopes }) as AuthInfo; + +// --- shouldRegisterTool --- + +test("shouldRegisterTool: public tool with no auth is visible", () => { + assert.strictEqual(shouldRegisterTool(baseMeta()), true); +}); + +test("shouldRegisterTool: enabled:false hides the tool by default", () => { + assert.strictEqual( + shouldRegisterTool(baseMeta({ enabled: false })), + false + ); +}); + +test("shouldRegisterTool: enabled:false is overridden by includedInConfig", () => { + assert.strictEqual( + shouldRegisterTool(baseMeta({ enabled: false }), undefined, true), + true + ); +}); + +test("shouldRegisterTool: requiresAuth without authInfo hides the tool", () => { + assert.strictEqual( + shouldRegisterTool(baseMeta({ requiresAuth: true })), + false + ); +}); + +test("shouldRegisterTool: requiresAuth with authInfo allows the tool", () => { + assert.strictEqual( + shouldRegisterTool(baseMeta({ requiresAuth: true }), auth()), + true + ); +}); + +test("shouldRegisterTool: requiredScopes imply auth and hide without authInfo", () => { + assert.strictEqual( + shouldRegisterTool(baseMeta({ requiredScopes: ["read"] })), + false + ); +}); + +test("shouldRegisterTool: required scope subset of user scopes → visible", () => { + assert.strictEqual( + shouldRegisterTool( + baseMeta({ requiredScopes: ["read"] }), + auth(["read", "write"]) + ), + true + ); +}); + +test("shouldRegisterTool: missing one required scope → hidden", () => { + assert.strictEqual( + shouldRegisterTool( + baseMeta({ requiredScopes: ["read", "admin"] }), + auth(["read"]) + ), + false + ); +}); + +test("shouldRegisterTool: multi-scope all present → visible", () => { + assert.strictEqual( + shouldRegisterTool( + baseMeta({ requiredScopes: ["read:users", "write:reports"] }), + auth(["read:users", "write:reports", "admin"]) + ), + true + ); +}); + +// --- resolveDependencies --- + +const buildCandidates = ( + specs: Array<[string, string[]?]> +): Map => { + const m = new Map(); + for (const [name, dependsOn] of specs) { + m.set(name, baseMeta({ name, dependsOn })); + } + return m; +}; + +test("resolveDependencies: no-dep tools all pass through", () => { + const candidates = buildCandidates([["a"], ["b"], ["c"]]); + const resolved = resolveDependencies( + candidates, + new Set(["a", "b", "c"]) + ); + assert.deepStrictEqual( + [...resolved].sort(), + ["a", "b", "c"] + ); +}); + +test("resolveDependencies: linear chain a→b→c all present → all resolve", () => { + const candidates = buildCandidates([ + ["a", ["b"]], + ["b", ["c"]], + ["c"], + ]); + const resolved = resolveDependencies( + candidates, + new Set(["a", "b", "c"]) + ); + assert.deepStrictEqual( + [...resolved].sort(), + ["a", "b", "c"] + ); +}); + +test("resolveDependencies: multi-hop fan-out a→{b,c}, b→d", () => { + const candidates = buildCandidates([ + ["a", ["b", "c"]], + ["b", ["d"]], + ["c"], + ["d"], + ]); + const resolved = resolveDependencies( + candidates, + new Set(["a", "b", "c", "d"]) + ); + assert.deepStrictEqual( + [...resolved].sort(), + ["a", "b", "c", "d"] + ); +}); + +test("resolveDependencies: missing dep drops dependent but keeps siblings", () => { + // `a` depends on `missing` which is not in passedFilter; `b` is independent. + const candidates = buildCandidates([ + ["a", ["missing"]], + ["b"], + ]); + const resolved = resolveDependencies(candidates, new Set(["a", "b"])); + assert.strictEqual(resolved.has("a"), false); + assert.strictEqual(resolved.has("b"), true); +}); + +test("resolveDependencies: dep filtered out by auth drops dependent", () => { + // `a→b`, both in candidates but `b` didn't pass the filter (e.g., auth-gated). + const candidates = buildCandidates([ + ["a", ["b"]], + ["b"], + ]); + const resolved = resolveDependencies(candidates, new Set(["a"])); + assert.strictEqual(resolved.has("a"), false); + assert.strictEqual(resolved.has("b"), false); +}); + +test("resolveDependencies: self-dep is dropped", () => { + const candidates = buildCandidates([["a", ["a"]], ["b"]]); + const resolved = resolveDependencies(candidates, new Set(["a", "b"])); + assert.strictEqual(resolved.has("a"), false); + assert.strictEqual(resolved.has("b"), true); +}); + +test("resolveDependencies: cycle a↔b drops both, keeps unrelated c", () => { + const candidates = buildCandidates([ + ["a", ["b"]], + ["b", ["a"]], + ["c"], + ]); + const resolved = resolveDependencies( + candidates, + new Set(["a", "b", "c"]) + ); + assert.strictEqual(resolved.has("a"), false); + assert.strictEqual(resolved.has("b"), false); + assert.strictEqual(resolved.has("c"), true); +}); + +test("resolveDependencies: large cycle terminates (no infinite loop)", () => { + // 20-node cycle to ensure maxIterations guard holds. + const specs: Array<[string, string[]?]> = []; + for (let i = 0; i < 20; i++) { + specs.push([`t${i}`, [`t${(i + 1) % 20}`]]); + } + const candidates = buildCandidates(specs); + const passed = new Set(specs.map(([n]) => n)); + const start = Date.now(); + const resolved = resolveDependencies(candidates, passed); + assert.ok( + Date.now() - start < 1000, + "should terminate quickly via maxIterations" + ); + assert.strictEqual(resolved.size, 0); +}); + +test("resolveDependencies: debug-silent when XMCP_DEBUG is unset", () => { + const originalDebug = process.env.XMCP_DEBUG; + delete process.env.XMCP_DEBUG; + const originalWarn = console.warn; + const calls: string[] = []; + console.warn = (msg: string) => calls.push(msg); + try { + const candidates = buildCandidates([["a", ["missing"]]]); + resolveDependencies(candidates, new Set(["a"])); + assert.strictEqual( + calls.length, + 0, + `expected no warnings without XMCP_DEBUG, got: ${calls.join(" | ")}` + ); + } finally { + console.warn = originalWarn; + if (originalDebug !== undefined) process.env.XMCP_DEBUG = originalDebug; + } +}); + +test("resolveDependencies: debug-verbose logs names when XMCP_DEBUG=1", () => { + const originalDebug = process.env.XMCP_DEBUG; + process.env.XMCP_DEBUG = "1"; + const originalWarn = console.warn; + const calls: string[] = []; + console.warn = (msg: string) => calls.push(msg); + try { + const candidates = buildCandidates([["a", ["missing"]]]); + resolveDependencies(candidates, new Set(["a"])); + assert.ok( + calls.some((m) => m.includes('"a"') && m.includes("missing")), + `expected debug warning with names, got: ${calls.join(" | ")}` + ); + } finally { + console.warn = originalWarn; + if (originalDebug === undefined) delete process.env.XMCP_DEBUG; + else process.env.XMCP_DEBUG = originalDebug; + } +}); diff --git a/packages/xmcp/src/runtime/utils/debug.ts b/packages/xmcp/src/runtime/utils/debug.ts new file mode 100644 index 000000000..8992f6ef7 --- /dev/null +++ b/packages/xmcp/src/runtime/utils/debug.ts @@ -0,0 +1,6 @@ +export const isDebugEnabled = (): boolean => + process.env.XMCP_DEBUG === "1" || process.env.XMCP_DEBUG === "true"; + +export function debugWarn(msg: string): void { + if (isDebugEnabled()) console.warn(msg); +} diff --git a/packages/xmcp/src/runtime/utils/server.ts b/packages/xmcp/src/runtime/utils/server.ts index c61ca3598..fcd4d53b9 100644 --- a/packages/xmcp/src/runtime/utils/server.ts +++ b/packages/xmcp/src/runtime/utils/server.ts @@ -1,6 +1,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; import { Implementation } from "@modelcontextprotocol/sdk/types"; -import { addToolsToServer } from "./tools"; +import { + addToolsToServer, + prepareToolRegistry, + registerToolsForRequest, + type ToolRegistry, +} from "./tools"; import { addPromptsToServer, PromptArgsRawShape } from "./prompts"; import { ToolMetadata } from "@/types/tool"; import { PromptMetadata } from "@/types/prompt"; @@ -18,6 +23,7 @@ import { } from "./resource-loader"; import { loadToolModules, reportToolLoadIssues } from "./tool-loader"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; +import { debugWarn } from "./debug"; export type ToolFile = { metadata: ToolMetadata; @@ -55,6 +61,37 @@ export const injectedResources = INJECTED_RESOURCES as Record< export const INJECTED_CONFIG = SERVER_INFO as Implementation; +// Dedupe the auth-gated-tools warning so HTTP transports without auth +// middleware don't spam stderr on every unauthenticated request. +let authlessWarningEmitted = false; + +function warnOnUnreachableAuthTools( + toolModules: Map, + authInfo: AuthInfo | undefined +): void { + if (authInfo || authlessWarningEmitted) return; + + const skipped: string[] = []; + for (const tool of toolModules.values()) { + const m = tool.metadata; + if ( + m && + (m.requiresAuth === true || + (Array.isArray(m.requiredScopes) && m.requiredScopes.length > 0)) + ) { + skipped.push(typeof m.name === "string" ? m.name : ""); + } + } + + if (skipped.length === 0) return; + + console.warn( + `[xmcp] ${skipped.length} auth-gated tool(s) skipped: this transport did not provide authInfo. See https://xmcp.dev/docs/guides/dynamic-tool-discovery#auth-on-stdio` + ); + debugWarn(`[xmcp] Auth-gated tools skipped: ${skipped.join(", ")}`); + authlessWarningEmitted = true; +} + /* Loads all modules and injects them into the server */ // would be better as a class and use dependency injection perhaps export async function configureServer( @@ -63,11 +100,18 @@ export async function configureServer( promptModules: Map, resourceModules: Map, authInfo?: AuthInfo, - enableList?: string[] + enableList?: string[], + toolRegistry?: ToolRegistry ): Promise { uIResourceRegistry.clear(); - addToolsToServer(server, toolModules, authInfo, enableList); + warnOnUnreachableAuthTools(toolModules, authInfo); + + if (toolRegistry) { + registerToolsForRequest(server, toolRegistry, authInfo, enableList); + } else { + addToolsToServer(server, toolModules, authInfo, enableList); + } addPromptsToServer(server, promptModules); addResourcesToServer(server, resourceModules); return server; @@ -98,22 +142,52 @@ export const enableList: string[] | undefined = ? INJECTED_TOOLS_ENABLE : undefined; -export async function createServer(authInfo?: AuthInfo) { - const server = new McpServer(INJECTED_CONFIG); - const toolModulesPromise = loadTools(); - const promptModulesPromise = loadPrompts(); - const resourceModulesPromise = loadResources(); +type ServerBundle = { + toolModules: Map; + promptModules: Map; + resourceModules: Map; + toolRegistry: ToolRegistry; +}; + +// Cache the module load + tool-registry prep so HTTP transports don't +// re-extract metadata / re-check duplicates per request. Skipped in dev mode +// where `xmcp dev` hot-reloads the bundle. +let cachedBundle: ServerBundle | null = null; + +function isDevMode(): boolean { + return process.env.NODE_ENV === "development"; +} + +async function getServerBundle(): Promise { + if (!isDevMode() && cachedBundle) return cachedBundle; + const [toolModules, promptModules, resourceModules] = await Promise.all([ - toolModulesPromise, - promptModulesPromise, - resourceModulesPromise, + loadTools(), + loadPrompts(), + loadResources(), ]); - return configureServer( - server, + + const bundle: ServerBundle = { toolModules, promptModules, resourceModules, + toolRegistry: prepareToolRegistry(toolModules), + }; + + if (!isDevMode()) cachedBundle = bundle; + return bundle; +} + +export async function createServer(authInfo?: AuthInfo) { + const server = new McpServer(INJECTED_CONFIG); + const bundle = await getServerBundle(); + return configureServer( + server, + bundle.toolModules, + bundle.promptModules, + bundle.resourceModules, authInfo, - enableList + enableList, + bundle.toolRegistry ); } diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index d0bc0d9bd..121f2718d 100644 --- a/packages/xmcp/src/runtime/utils/tools.ts +++ b/packages/xmcp/src/runtime/utils/tools.ts @@ -10,6 +10,7 @@ import { flattenMeta, hasUIMeta } from "./ui/flatten-meta"; import { splitUIMetaNested } from "./ui/split-meta"; import { isPaidHandler, getX402Registry } from "@/plugins/x402"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; +import { debugWarn } from "./debug"; /** Validates if a value is a valid Zod schema object */ export function isZodRawShape(value: unknown): value is ZodRawShape { @@ -100,11 +101,12 @@ export function resolveDependencies( } } - // Warn about unresolved tools (circular deps or missing deps) + // Warn about unresolved tools (circular deps or missing deps). + // Names are gated behind XMCP_DEBUG to avoid leaking hidden tool identities. for (const [name, meta] of candidates) { if (passedFilter.has(name) && !resolved.has(name) && meta.dependsOn?.length) { const missing = meta.dependsOn.filter((d) => !resolved.has(d)); - console.warn( + debugWarn( `[xmcp] Tool "${name}" excluded: unresolved dependencies [${missing.join(", ")}]` ); } @@ -113,20 +115,29 @@ export function resolveDependencies( return resolved; } -/** Loads tools and injects them into the server, applying filtering */ -export function addToolsToServer( - server: McpServer, - toolModules: Map, - authInfo?: AuthInfo, - enableList?: string[] -): McpServer { - // --- Pass 1: Build tool configs and evaluate per-tool filters --- - const passedFilter = new Set(); - const candidates = new Map(); - const toolData = new Map< - string, - { path: string; toolModule: ToolFile; toolConfig: ToolMetadata } - >(); +export type ToolRegistryEntry = { + path: string; + toolModule: ToolFile; + toolConfig: ToolMetadata; +}; + +/** Auth-invariant tool data precomputed once at startup. */ +export type ToolRegistry = { + entries: Map; +}; + +/** + * Build an auth-invariant registry of tools keyed by canonical name. + * + * Runs once at startup (cached in server.ts for HTTP transports). Extracts + * metadata, applies defaults, and runs the defense-in-depth duplicate check. + * Duplicates here indicate a miscompiled bundle — build-time + * `assertNoDuplicateToolNames` (tool-discovery.ts) is the authoritative gate. + */ +export function prepareToolRegistry( + toolModules: Map +): ToolRegistry { + const entries = new Map(); toolModules.forEach((toolModule, path) => { const defaultName = pathToName(path); @@ -137,35 +148,49 @@ export function addToolsToServer( }; const { metadata } = toolModule; - if (typeof metadata === "object" && metadata !== null) { Object.assign(toolConfig, metadata); } - const includedInConfig = enableList?.includes(toolConfig.name) ?? false; - - if (!shouldRegisterTool(toolConfig, authInfo, includedInConfig)) { - return; // skip this tool - } - - // Warn on duplicate tool names - const existing = toolData.get(toolConfig.name); + const existing = entries.get(toolConfig.name); if (existing) { - throw new Error( - `[xmcp] Duplicate tool name "${toolConfig.name}" found in "${existing.path}" and "${path}". Rename one tool or remove one file.` + debugWarn( + `[xmcp] Duplicate tool name collision at runtime: "${toolConfig.name}" in "${existing.path}" and "${path}"` ); + return; } - passedFilter.add(toolConfig.name); - candidates.set(toolConfig.name, toolConfig); - toolData.set(toolConfig.name, { path, toolModule, toolConfig }); + entries.set(toolConfig.name, { path, toolModule, toolConfig }); }); - // --- Pass 2: Resolve dependencies --- + return { entries }; +} + +/** + * Per-request: apply the auth/scope/enabled filter over a precomputed + * registry, resolve dependencies, and register the survivors on the server. + */ +export function registerToolsForRequest( + server: McpServer, + registry: ToolRegistry, + authInfo?: AuthInfo, + enableList?: string[] +): McpServer { + const passedFilter = new Set(); + const candidates = new Map(); + + for (const [name, entry] of registry.entries) { + const includedInConfig = enableList?.includes(name) ?? false; + if (!shouldRegisterTool(entry.toolConfig, authInfo, includedInConfig)) { + continue; + } + passedFilter.add(name); + candidates.set(name, entry.toolConfig); + } + const resolved = resolveDependencies(candidates, passedFilter); - // --- Pass 3: Register surviving tools --- - for (const [name, data] of toolData) { + for (const [name, data] of registry.entries) { if (!resolved.has(name)) continue; const { path, toolModule, toolConfig } = data; @@ -295,3 +320,17 @@ export function addToolsToServer( return server; } + +/** + * Back-compat shim. Prefer `prepareToolRegistry` + `registerToolsForRequest` + * when the registry can be cached across requests. + */ +export function addToolsToServer( + server: McpServer, + toolModules: Map, + authInfo?: AuthInfo, + enableList?: string[] +): McpServer { + const registry = prepareToolRegistry(toolModules); + return registerToolsForRequest(server, registry, authInfo, enableList); +}