diff --git a/apps/website/content/docs/core-concepts/tools.mdx b/apps/website/content/docs/core-concepts/tools.mdx index 304645336..13ce2cc25 100644 --- a/apps/website/content/docs/core-concepts/tools.mdx +++ b/apps/website/content/docs/core-concepts/tools.mdx @@ -173,6 +173,30 @@ 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. + +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 new file mode 100644 index 000000000..3065dfd88 --- /dev/null +++ b/apps/website/content/docs/guides/dynamic-tool-discovery.mdx @@ -0,0 +1,281 @@ +--- +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." +--- + +## Overview + +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. + +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. + +Filtering happens in three layers, evaluated in order: + +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 + +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: + +| 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. The tool only appears in `tools/list` when the request includes a valid access token. + +```typescript title="src/tools/user-profile.ts" +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", + description: "Retrieve the authenticated user's profile.", + requiresAuth: true, +}; + +export default async function userProfile({ + field, +}: InferSchema) { + return `Profile field "${field}" for the authenticated user.`; +} +``` + +### Require specific scopes + +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 { 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. Requires admin scope.", + requiredScopes: ["admin"], +}; + +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 authentication is required. You can omit `requiresAuth` when scopes are declared, but including it makes the intent clearer. + + +### Declare tool dependencies + +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 { 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. Available when admin-panel is accessible.", + dependsOn: ["admin-panel"], +}; + +export default async function advancedAnalytics({ + report, +}: InferSchema) { + return `Analytics report "${report}" generated.`; +} +``` + +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, +}; + +export default async function experimental() { + return "This feature is enabled via server configuration."; +} +``` + +## Server configuration + +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. + +### 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"; + +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. + + + `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. + + +### Exclude list + +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. This works alongside `exclude` or on its own. + +```typescript title="xmcp.config.ts" +const config: XmcpConfig = { + http: true, + tools: { + exclude: ["debug-tool"], + enable: ["experimental"], + }, +}; + +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"], + }, +}; +``` + +## 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: + +- **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/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/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/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..c23c37679 --- /dev/null +++ b/examples/dynamic-tool-discovery/xmcp.config.ts @@ -0,0 +1,28 @@ +import { XmcpConfig } from "xmcp"; + +const config: XmcpConfig = { + http: true, + 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"], + }, +}; + +export default config; 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/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..f05a6e190 100644 --- a/packages/xmcp/src/compiler/generate-import-code.ts +++ b/packages/xmcp/src/compiler/generate-import-code.ts @@ -1,4 +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. @@ -8,23 +10,84 @@ function pathToIdentifier(path: string): string { return path.replace(/[^a-zA-Z0-9]/g, "_"); } -export function generateImportCode(): string { +/** 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[], + toolsConfig?: ToolsConfig +): Set { + 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(); + 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"); + } + return filtered; + } + + if (toolsConfig.exclude) { + const excludeSet = new Set(toolsConfig.exclude); + const filtered = new Set(); + 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; + } + + return toolPaths; +} + +export function generateImportCode(toolEntries: ResolvedToolEntry[]): string { const { - toolPaths, promptPaths, resourcePaths, hasMiddleware, clientBundles, platforms, + xmcpConfig, } = compilerContext.getContext(); + const filteredToolPaths = filterToolPaths(toolEntries, 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 +96,7 @@ export function generateImportCode(): string { } return generateDynamicImportCode( - toolPaths, + filteredToolPaths, promptPaths, resourcePaths, hasMiddleware, 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/get-bundler-config/get-injected-variables.ts b/packages/xmcp/src/compiler/get-bundler-config/get-injected-variables.ts index d3ce2a488..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 @@ -33,6 +33,25 @@ 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 ?? []), + ]; + + // 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, @@ -42,5 +61,7 @@ export function getInjectedVariables( ...serverInfoVariables, ...adapterVariables, ...typescriptVariables, + INJECTED_TOOLS_ENABLE: + enableList.length > 0 ? JSON.stringify(enableList) : "undefined", }; } diff --git a/packages/xmcp/src/compiler/index.ts b/packages/xmcp/src/compiler/index.ts index 4d1f7abce..4c1f242b7 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"; @@ -34,10 +35,7 @@ import { getResolvedPathsConfig } from "./config/utils"; import { pathToToolName } from "./utils/path-utils"; import { transpileClientComponent } from "./client/transpile"; import { buildCloudflareOutput } from "../platforms/build-cloudflare-output"; -import { - addWatchedPath, - removeWatchedPath, -} from "./watcher-recovery"; +import { addWatchedPath, removeWatchedPath } from "./watcher-recovery"; const { version: XMCP_VERSION } = require("../../package.json"); dotenv.config(); @@ -445,18 +443,23 @@ async function generateCode({ }: { rebuildClientBundles?: boolean; } = {}) { - const { clientBundles: currentClientBundles } = compilerContext.getContext(); + const { clientBundles: currentClientBundles, toolPaths } = + compilerContext.getContext(); const clientBundles = rebuildClientBundles || currentClientBundles === undefined ? await buildClientBundles() : currentClientBundles; + 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(); - writeFileIfChanged(path.join(runtimeFolderPath, "import-map.js"), fileContent); + const fileContent = generateImportCode(toolEntries); + writeFileIfChanged( + path.join(runtimeFolderPath, "import-map.js"), + fileContent + ); // Generate runtime exports for global access const runtimeExportsCode = generateEnvCode(); @@ -466,9 +469,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..d62bdfd4f --- /dev/null +++ b/packages/xmcp/src/compiler/tool-discovery.ts @@ -0,0 +1,171 @@ +import fs from "node:fs"; +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 { + 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; +} + +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; + 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.` + ); + } +} + +/** + * 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 { + 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), + metadataSnapshot: extractMetadataSnapshot(metadata), + }; + } 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); + validateToolMetadata(entries); + return entries; +} diff --git a/packages/xmcp/src/runtime/adapters/express/index.ts b/packages/xmcp/src/runtime/adapters/express/index.ts index 26f549193..d3d42a128 100644 --- a/packages/xmcp/src/runtime/adapters/express/index.ts +++ b/packages/xmcp/src/runtime/adapters/express/index.ts @@ -39,7 +39,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 7296273dc..27abb6cb9 100644 --- a/packages/xmcp/src/runtime/adapters/nestjs/xmcp.service.ts +++ b/packages/xmcp/src/runtime/adapters/nestjs/xmcp.service.ts @@ -50,7 +50,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 a62d2ceb7..3426bfe3a 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts @@ -4,10 +4,12 @@ import { StatelessHttpServerTransport } from "@/runtime/transports/http/stateles import { configureServer, INJECTED_CONFIG, + enableList, loadPrompts, loadResources, loadTools, } from "@/runtime/utils/server"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; export interface ServerLifecycle { server: McpServer; @@ -36,7 +38,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 promptModulesPromise = loadPrompts(); const resourceModulesPromise = loadResources(); @@ -48,7 +52,14 @@ export async function initializeMcpServer(): Promise { const server = new McpServer(INJECTED_CONFIG); - await configureServer(server, toolModules, promptModules, resourceModules); + await configureServer( + server, + toolModules, + promptModules, + resourceModules, + authInfo, + enableList + ); return server; } @@ -57,9 +68,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 30047faf5..9ca8b854d 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/index.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/index.ts @@ -9,12 +9,9 @@ import { setupCleanupHandlers, } from "./handler/server-lifecycle"; import { createIncomingMessage } from "./handler/request-converter"; -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; import { httpRequestContextProvider } from "@/runtime/contexts/http-request-context"; import { randomUUID } from "node:crypto"; -import { - setHttpRequestContext, -} from "@/runtime/contexts/http-request-context"; +import { setHttpRequestContext } from "@/runtime/contexts/http-request-context"; import { extractClientInfoFromMessages } from "@/runtime/utils/client-info"; const BODY_SIZE_LIMIT = "10mb"; @@ -35,38 +32,41 @@ export async function xmcpHandler(request: Request): Promise { return httpRequestContextProvider( { id, headers: requestHeaders, clientInfo: undefined }, () => { - return nodeToWebAdapter(request.signal, async (res: ServerResponse) => { - try { - // Initialize server and transport - const lifecycle = await createServerLifecycle(BODY_SIZE_LIMIT); + return nodeToWebAdapter(request.signal, async (res: ServerResponse) => { + try { + // Initialize server and transport + const lifecycle = await createServerLifecycle( + BODY_SIZE_LIMIT, + request.auth + ); - // Setup cleanup handlers - setupCleanupHandlers(res, lifecycle); + // Setup cleanup handlers + setupCleanupHandlers(res, lifecycle); - // Parse request body - const bodyContent = await request.json(); - const clientInfo = extractClientInfoFromMessages(bodyContent); - setHttpRequestContext({ clientInfo }); + // Parse request body + const bodyContent = await request.json(); + const clientInfo = extractClientInfoFromMessages(bodyContent); + setHttpRequestContext({ clientInfo }); - // Convert Web Request to Node.js IncomingMessage - const incomingRequest = createIncomingMessage({ - method: request.method, - url: request.url, - headers: requestHeaders, - auth: request.auth, - }); + // Convert Web Request to Node.js IncomingMessage + const incomingRequest = createIncomingMessage({ + method: request.method, + url: request.url, + headers: requestHeaders, + auth: request.auth, + }); - // Handle request through transport - await lifecycle.transport.handleRequest( - incomingRequest, - res, - bodyContent - ); - } catch (error) { - console.error("[Next.js MCP] Error handling MCP request:", error); - sendInternalServerError(res); - } - }); + // Handle request through transport + await lifecycle.transport.handleRequest( + incomingRequest, + res, + bodyContent + ); + } catch (error) { + console.error("[Next.js MCP] Error handling MCP request:", error); + sendInternalServerError(res); + } + }); } ); } diff --git a/packages/xmcp/src/runtime/platforms/cloudflare/worker.ts b/packages/xmcp/src/runtime/platforms/cloudflare/worker.ts index 9a28dfa29..7f94e3711 100644 --- a/packages/xmcp/src/runtime/platforms/cloudflare/worker.ts +++ b/packages/xmcp/src/runtime/platforms/cloudflare/worker.ts @@ -140,7 +140,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 5e6b2cc20..5a5448f17 100644 --- a/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts +++ b/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts @@ -107,13 +107,13 @@ 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; private sessions = new Map(); constructor( - createServerFn: () => Promise, + createServerFn: (authInfo?: AuthInfo) => Promise, options: HttpTransportOptions = {}, corsConfig: CorsConfig = corsConfigSchema.parse({}), providers?: Provider[] @@ -255,7 +255,7 @@ export class StatelessStreamableHTTPTransport { } private async handleStatelessRequest( - req: Request, + req: Request & { auth?: AuthInfo }, res: Response ): Promise { try { @@ -288,7 +288,7 @@ export class StatelessStreamableHTTPTransport { return; } - lifecycle = await this.createSessionLifecycle(); + lifecycle = await this.createSessionLifecycle(req.auth); } if (!clientInfo) { @@ -335,8 +335,10 @@ export class StatelessStreamableHTTPTransport { return typeof header === "string" && header.length > 0 ? header : undefined; } - private async createSessionLifecycle(): Promise { - const server = await this.createServerFn(); + private async createSessionLifecycle( + authInfo?: AuthInfo + ): Promise { + const server = await this.createServerFn(authInfo); const transport = new SdkStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), }); 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 0069954ff..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"; @@ -17,6 +22,8 @@ import { reportResourceLoadIssues, } 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; @@ -54,17 +61,57 @@ 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( server: McpServer, toolModules: Map, promptModules: Map, - resourceModules: Map + resourceModules: Map, + authInfo?: AuthInfo, + enableList?: string[], + toolRegistry?: ToolRegistry ): Promise { uIResourceRegistry.clear(); - addToolsToServer(server, toolModules); + warnOnUnreachableAuthTools(toolModules, authInfo); + + if (toolRegistry) { + registerToolsForRequest(server, toolRegistry, authInfo, enableList); + } else { + addToolsToServer(server, toolModules, authInfo, enableList); + } addPromptsToServer(server, promptModules); addResourcesToServer(server, resourceModules); return server; @@ -77,30 +124,70 @@ export async function loadTools() { } export async function loadPrompts() { - const { promptModules, skippedPrompts } = await loadPromptModules( - injectedPrompts - ); + const { promptModules, skippedPrompts } = + await loadPromptModules(injectedPrompts); reportPromptLoadIssues(skippedPrompts); return promptModules; } export async function loadResources() { - const { resourceModules, skippedResources } = await loadResourceModules( - injectedResources - ); + const { resourceModules, skippedResources } = + await loadResourceModules(injectedResources); reportResourceLoadIssues(skippedResources); return resourceModules; } -export async function createServer() { - const server = new McpServer(INJECTED_CONFIG); - const toolModulesPromise = loadTools(); - const promptModulesPromise = loadPrompts(); - const resourceModulesPromise = loadResources(); +export const enableList: string[] | undefined = + typeof INJECTED_TOOLS_ENABLE !== "undefined" + ? INJECTED_TOOLS_ENABLE + : undefined; + +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, toolModules, promptModules, resourceModules); + + 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, + bundle.toolRegistry + ); } diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index b61049b20..121f2718d 100644 --- a/packages/xmcp/src/runtime/utils/tools.ts +++ b/packages/xmcp/src/runtime/utils/tools.ts @@ -9,6 +9,8 @@ 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"; +import { debugWarn } from "./debug"; /** Validates if a value is a valid Zod schema object */ export function isZodRawShape(value: unknown): value is ZodRawShape { @@ -38,11 +40,105 @@ 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). + // 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)); + debugWarn( + `[xmcp] Tool "${name}" excluded: unresolved dependencies [${missing.join(", ")}]` + ); + } + } + + return resolved; +} + +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 -): McpServer { +): ToolRegistry { + const entries = new Map(); + toolModules.forEach((toolModule, path) => { const defaultName = pathToName(path); @@ -51,12 +147,55 @@ 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 existing = entries.get(toolConfig.name); + if (existing) { + debugWarn( + `[xmcp] Duplicate tool name collision at runtime: "${toolConfig.name}" in "${existing.path}" and "${path}"` + ); + return; + } + + entries.set(toolConfig.name, { path, toolModule, toolConfig }); + }); + + 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); + + for (const [name, data] of registry.entries) { + 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 +316,21 @@ export function addToolsToServer( toolConfigFormatted, transformedHandler ); - }); + } 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); +} 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 d5bcfe0e0..c33f628f6 100644 --- a/packages/xmcp/src/types/tool.ts +++ b/packages/xmcp/src/types/tool.ts @@ -30,6 +30,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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71b1ae65b..5fc410ae5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,6 +489,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: