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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/website/content/docs/core-concepts/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,30 @@ annotations: {
about when and how to call your tools, but they don't enforce any behavior.
</Callout>

### 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

<Callout variant="info">
Expand Down
281 changes: 281 additions & 0 deletions apps/website/content/docs/guides/dynamic-tool-discovery.mdx
Original file line number Diff line number Diff line change
@@ -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<typeof schema>) {
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<typeof schema>) {
return `Admin action "${action}" executed successfully.`;
}
```

A client with scopes `["read"]` won't see this tool. A client with scopes `["admin", "read"]` will.

<Callout variant="info">
Setting `requiredScopes` implies authentication is required. You can omit `requiresAuth` when scopes are declared, but including it makes the intent clearer.
</Callout>

### 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<typeof schema>) {
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.

<Callout variant="info">
`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.
</Callout>

<Callout variant="info">
`include`, `exclude`, and `enable` match the tool's public name from `metadata.name`, not the source filename.
</Callout>

### 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;
```

<Callout variant="warning">
`include` and `exclude` cannot be used together. The server will reject this configuration with an error.
</Callout>

### 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`).
2 changes: 1 addition & 1 deletion apps/website/content/docs/guides/meta.json
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions examples/dynamic-tool-discovery/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.vercel
.xmcp
xmcp-env.d.ts
19 changes: 19 additions & 0 deletions examples/dynamic-tool-discovery/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
28 changes: 28 additions & 0 deletions examples/dynamic-tool-discovery/src/tools/admin-panel.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schema>) {
return `Admin action "${action}" executed successfully.`;
}
Loading
Loading