From 1dd834b22c14c0d487b4a725eed3f67c9e5e72c3 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Wed, 25 Mar 2026 23:12:28 -0300 Subject: [PATCH 1/6] Initial --- .../website/content/docs/guides/code-mode.mdx | 271 ++++++++++++++++++ apps/website/content/docs/guides/meta.json | 2 +- examples/code-mode-http/package.json | 18 ++ .../code-mode-http/src/tools/create-user.ts | 25 ++ examples/code-mode-http/src/tools/execute.ts | 21 ++ examples/code-mode-http/src/tools/get-user.ts | 26 ++ .../code-mode-http/src/tools/list-users.ts | 17 ++ examples/code-mode-http/src/tools/search.ts | 33 +++ examples/code-mode-http/xmcp.config.ts | 10 + packages/xmcp/src/index.ts | 2 + packages/xmcp/src/runtime/utils/tools.ts | 27 +- packages/xmcp/src/types/tool.ts | 38 +++ 12 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 apps/website/content/docs/guides/code-mode.mdx create mode 100644 examples/code-mode-http/package.json create mode 100644 examples/code-mode-http/src/tools/create-user.ts create mode 100644 examples/code-mode-http/src/tools/execute.ts create mode 100644 examples/code-mode-http/src/tools/get-user.ts create mode 100644 examples/code-mode-http/src/tools/list-users.ts create mode 100644 examples/code-mode-http/src/tools/search.ts create mode 100644 examples/code-mode-http/xmcp.config.ts diff --git a/apps/website/content/docs/guides/code-mode.mdx b/apps/website/content/docs/guides/code-mode.mdx new file mode 100644 index 000000000..940ab7bcc --- /dev/null +++ b/apps/website/content/docs/guides/code-mode.mdx @@ -0,0 +1,271 @@ +--- +title: "Code Mode" +metadataTitle: "Code Mode Pattern Guide | xmcp Documentation" +publishedAt: "2026-03-25" +summary: "Reduce tool context bloat by exposing your entire API surface through just 2 meta-tools." +description: "Learn how to implement the Code Mode pattern in xmcp — replace hundreds of individual MCP tools with search and execute meta-tools for massive token savings." +--- + +## Overview + +MCP servers with many tools create a problem: every tool definition (name, description, full schema) is loaded into the agent's context window upfront. A server with 100 tools can consume 50,000+ tokens before the agent starts working. + +Code Mode solves this with a simple pattern: instead of exposing all tools directly, expose just **2 meta-tools**: + +- **`search`** — Agents query tool metadata to discover what's available +- **`execute`** — Agents invoke any tool by name + +This reduces the tool definition footprint from tens of thousands of tokens to under 1,000 — a 99%+ reduction. The pattern was [popularized by Cloudflare](https://blog.cloudflare.com/code-mode-mcp/) for their 2,500+ endpoint API. + +## When to use Code Mode + +**Use Code Mode when** your server exposes many tools (20+) that an agent rarely needs all at once. Common cases: API wrappers, enterprise integrations, multi-service orchestrators, or any server where tool discovery is more efficient than upfront loading. + +**Code Mode may not be necessary** for servers with a small, focused set of tools (under 10-15) where loading all definitions upfront is acceptable. + +## How it works + +xmcp provides two primitives in every tool handler's `extra` parameter: + +- **`extra.listTools()`** — Returns metadata and JSON Schema for all registered tools +- **`extra.callTool(name, args)`** — Invokes another tool by name, validates args, and returns the result + +You use these to build your own `search` and `execute` tools — no plugins or special configuration needed. + +## Quickstart + +Create two tool files in your xmcp project: + +### 1. The search tool + +```typescript title="src/tools/search.ts" +import { z } from "zod"; +import type { ToolMetadata } from "xmcp"; + +export const schema = { + query: z.string().describe("Search query to filter available tools"), +}; + +export const metadata: ToolMetadata = { + name: "search", + description: + "Search available tools by name or description. Returns matching tools with their input schemas so you know how to call them.", + annotations: { + readOnlyHint: true, + }, +}; + +export default async function search({ query }, extra) { + const tools = extra.listTools(); + const q = query.toLowerCase(); + + const matches = tools.filter( + (t) => + t.name.toLowerCase().includes(q) || + t.description.toLowerCase().includes(q) + ); + + return JSON.stringify(matches, null, 2); +} +``` + +### 2. The execute tool + +```typescript title="src/tools/execute.ts" +import { z } from "zod"; +import type { ToolMetadata } from "xmcp"; + +export const schema = { + toolName: z.string().describe("Name of the tool to execute"), + args: z.record(z.any()).describe("Arguments to pass to the tool"), +}; + +export const metadata: ToolMetadata = { + name: "execute", + description: + "Execute any available tool by name. Use the search tool first to discover available tools and their required arguments.", + annotations: { + openWorldHint: true, + }, +}; + +export default async function execute({ toolName, args }, extra) { + return await extra.callTool(toolName, args); +} +``` + +That's it. Your server now exposes just `search` and `execute` to agents, while all your other tools are accessible through them. + +### 3. Your actual tools + +Keep building tools as normal. They're still registered with xmcp — they're just accessed through the `execute` meta-tool instead of directly: + +```typescript title="src/tools/get-user.ts" +import { z } from "zod"; +import type { ToolMetadata } from "xmcp"; + +export const schema = { + userId: z.string().describe("The user ID to look up"), +}; + +export const metadata: ToolMetadata = { + name: "get-user", + description: "Retrieve user profile information by ID", +}; + +export default async function getUser({ userId }) { + const user = await db.users.findById(userId); + return JSON.stringify(user); +} +``` + +## How agents interact with Code Mode + +An agent connecting to your server sees only 2 tools. Here's a typical interaction: + +**Step 1:** Agent calls `search` to find relevant tools: + +```json +{ + "tool": "search", + "args": { "query": "user" } +} +``` + +Response: + +```json +[ + { + "name": "get-user", + "description": "Retrieve user profile information by ID", + "inputSchema": { + "type": "object", + "properties": { + "userId": { "type": "string", "description": "The user ID to look up" } + }, + "required": ["userId"] + } + }, + { + "name": "update-user", + "description": "Update user profile fields", + "inputSchema": { "..." } + } +] +``` + +**Step 2:** Agent calls `execute` with the discovered tool: + +```json +{ + "tool": "execute", + "args": { + "toolName": "get-user", + "args": { "userId": "usr_123" } + } +} +``` + +The agent gets the same result as if it had called `get-user` directly. + +## Tool chaining + +Since `callTool` is available in any handler, you can also build orchestrator tools that chain multiple calls: + +```typescript title="src/tools/onboard-user.ts" +import { z } from "zod"; +import type { ToolMetadata } from "xmcp"; + +export const schema = { + email: z.string().email(), + name: z.string(), +}; + +export const metadata: ToolMetadata = { + name: "onboard-user", + description: "Create a user account and send welcome email", +}; + +export default async function onboardUser({ email, name }, extra) { + const user = await extra.callTool("create-user", { email, name }); + await extra.callTool("send-welcome-email", { userId: user.id }); + return user; +} +``` + +## Combining with authentication + +Code Mode works with any xmcp auth middleware. The auth context automatically propagates through `callTool`: + +```typescript title="src/middleware.ts" +import { jwtAuthMiddleware } from "xmcp"; + +export default jwtAuthMiddleware({ + secret: process.env.JWT_SECRET, +}); +``` + +When an authenticated agent calls `execute`, the called tool receives the same `authInfo` — no additional setup needed. + +## Advanced: filtering the tool list + +You may want to exclude the meta-tools themselves from search results, or only expose a subset of tools: + +```typescript title="src/tools/search.ts" +export default async function search({ query }, extra) { + const tools = extra.listTools(); + const hidden = new Set(["search", "execute"]); + + const matches = tools + .filter((t) => !hidden.has(t.name)) + .filter( + (t) => + t.name.toLowerCase().includes(query.toLowerCase()) || + t.description.toLowerCase().includes(query.toLowerCase()) + ); + + return JSON.stringify(matches, null, 2); +} +``` + +## Advanced: sandboxed code execution + +For the full Cloudflare-style experience where agents write JavaScript code (not just tool names), you can add a sandbox library like `quickjs-emscripten`: + +```typescript title="src/tools/execute.ts" +import { getQuickJS } from "quickjs-emscripten"; + +export default async function execute({ code }, extra) { + const QuickJS = await getQuickJS(); + const vm = QuickJS.newContext(); + + // Inject tools as callable functions in the sandbox + const toolsHandle = vm.newObject(); + for (const tool of extra.listTools()) { + const fn = vm.newFunction(tool.name, (...handles) => { + const args = JSON.parse(vm.getString(handles[0])); + return extra.callTool(tool.name, args); + }); + vm.setProp(toolsHandle, tool.name, fn); + fn.dispose(); + } + vm.setProp(vm.global, "tools", toolsHandle); + toolsHandle.dispose(); + + const result = vm.evalCode(code); + const output = vm.getString(result.value); + result.value.dispose(); + vm.dispose(); + + return output; +} +``` + +This is an application-level choice — xmcp provides the primitives, you decide the execution model. + +## References + +- [Cloudflare Code Mode announcement](https://blog.cloudflare.com/code-mode-mcp/) — The original pattern +- [Tools documentation](/docs/core-concepts/tools) — How xmcp tools work +- [Authentication guide](/docs/guides/authentication) — Combining Code Mode with auth diff --git a/apps/website/content/docs/guides/meta.json b/apps/website/content/docs/guides/meta.json index 79b9dca22..d07e1f3e1 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", "code-mode"], "defaultOpen": true, "root": true } diff --git a/examples/code-mode-http/package.json b/examples/code-mode-http/package.json new file mode 100644 index 000000000..0f3545d44 --- /dev/null +++ b/examples/code-mode-http/package.json @@ -0,0 +1,18 @@ +{ + "name": "Code Mode HTTP", + "description": "Expose your entire tool surface through 2 meta-tools using the Code Mode pattern", + "keywords": [ + "http", + "code-mode", + "introspection" + ], + "scripts": { + "build": "xmcp build", + "dev": "xmcp dev", + "start": "node dist/http.js" + }, + "dependencies": { + "xmcp": "workspace:*", + "zod": "^4.0.10" + } +} diff --git a/examples/code-mode-http/src/tools/create-user.ts b/examples/code-mode-http/src/tools/create-user.ts new file mode 100644 index 000000000..dfd9ccdf8 --- /dev/null +++ b/examples/code-mode-http/src/tools/create-user.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import type { ToolMetadata } from "xmcp"; + +export const schema = { + name: z.string().describe("The user's full name"), + email: z.string().email().describe("The user's email address"), +}; + +export const metadata: ToolMetadata = { + name: "create-user", + description: "Create a new user account with name and email", + annotations: { destructiveHint: true }, +}; + +export default async function createUser({ + name, + email, +}: { + name: string; + email: string; +}) { + const id = String(Math.floor(Math.random() * 10000)); + const user = { id, name, email }; + return JSON.stringify(user); +} diff --git a/examples/code-mode-http/src/tools/execute.ts b/examples/code-mode-http/src/tools/execute.ts new file mode 100644 index 000000000..b545024e3 --- /dev/null +++ b/examples/code-mode-http/src/tools/execute.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import type { ToolMetadata, ToolExtraArguments } from "xmcp"; + +export const schema = { + toolName: z.string().describe("Name of the tool to execute"), + args: z.record(z.any()).describe("Arguments to pass to the tool"), +}; + +export const metadata: ToolMetadata = { + name: "execute", + description: + "Execute any available tool by name. Use the search tool first to discover tools and their required arguments.", + annotations: { openWorldHint: true }, +}; + +export default async function execute( + { toolName, args }: { toolName: string; args: Record }, + extra: ToolExtraArguments +) { + return await extra.callTool(toolName, args); +} diff --git a/examples/code-mode-http/src/tools/get-user.ts b/examples/code-mode-http/src/tools/get-user.ts new file mode 100644 index 000000000..fc87d2aef --- /dev/null +++ b/examples/code-mode-http/src/tools/get-user.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import type { ToolMetadata } from "xmcp"; + +export const schema = { + userId: z.string().describe("The user ID to look up"), +}; + +export const metadata: ToolMetadata = { + name: "get-user", + description: "Retrieve user profile information by ID", + annotations: { readOnlyHint: true }, +}; + +const users: Record = { + "1": { id: "1", name: "Alice", email: "alice@example.com" }, + "2": { id: "2", name: "Bob", email: "bob@example.com" }, + "3": { id: "3", name: "Charlie", email: "charlie@example.com" }, +}; + +export default async function getUser({ userId }: { userId: string }) { + const user = users[userId]; + if (!user) { + return `User with ID "${userId}" not found`; + } + return JSON.stringify(user); +} diff --git a/examples/code-mode-http/src/tools/list-users.ts b/examples/code-mode-http/src/tools/list-users.ts new file mode 100644 index 000000000..7048b7b8b --- /dev/null +++ b/examples/code-mode-http/src/tools/list-users.ts @@ -0,0 +1,17 @@ +import type { ToolMetadata } from "xmcp"; + +export const metadata: ToolMetadata = { + name: "list-users", + description: "List all users in the system", + annotations: { readOnlyHint: true }, +}; + +const users = [ + { id: "1", name: "Alice", email: "alice@example.com" }, + { id: "2", name: "Bob", email: "bob@example.com" }, + { id: "3", name: "Charlie", email: "charlie@example.com" }, +]; + +export default async function listUsers() { + return JSON.stringify(users); +} diff --git a/examples/code-mode-http/src/tools/search.ts b/examples/code-mode-http/src/tools/search.ts new file mode 100644 index 000000000..7c8c20718 --- /dev/null +++ b/examples/code-mode-http/src/tools/search.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import type { ToolMetadata, ToolExtraArguments } from "xmcp"; + +export const schema = { + query: z.string().describe("Search query to filter available tools"), +}; + +export const metadata: ToolMetadata = { + name: "search", + description: + "Search available tools by name or description. Returns matching tools with their input schemas so you know how to call them.", + annotations: { readOnlyHint: true }, +}; + +const META_TOOLS = new Set(["search", "execute"]); + +export default async function search( + { query }: { query: string }, + extra: ToolExtraArguments +) { + const tools = extra.listTools(); + const q = query.toLowerCase(); + + const matches = tools + .filter((t) => !META_TOOLS.has(t.name)) + .filter( + (t) => + t.name.toLowerCase().includes(q) || + t.description.toLowerCase().includes(q) + ); + + return JSON.stringify(matches, null, 2); +} diff --git a/examples/code-mode-http/xmcp.config.ts b/examples/code-mode-http/xmcp.config.ts new file mode 100644 index 000000000..060a5943d --- /dev/null +++ b/examples/code-mode-http/xmcp.config.ts @@ -0,0 +1,10 @@ +import { XmcpConfig } from "xmcp"; + +const config: XmcpConfig = { + http: true, + typescript: { + skipTypeCheck: true, + }, +}; + +export default config; diff --git a/packages/xmcp/src/index.ts b/packages/xmcp/src/index.ts index 2b28e5b30..cae67e456 100644 --- a/packages/xmcp/src/index.ts +++ b/packages/xmcp/src/index.ts @@ -7,6 +7,8 @@ export type { ToolSchema, ToolOutputSchema, ToolExtraArguments, + ToolInfo, + CallToolResultCompat, InferSchema, } from "./types/tool"; export type { PromptMetadata } from "./types/prompt"; diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index ec69c7922..5b4f66190 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 { ToolRegistry } from "./tool-registry"; /** Validates if a value is a valid Zod schema object */ export function isZodRawShape(value: unknown): value is ZodRawShape { @@ -35,6 +36,8 @@ export function addToolsToServer( server: McpServer, toolModules: Map ): McpServer { + const registry = new ToolRegistry(); + toolModules.forEach((toolModule, path) => { const defaultName = pathToName(path); @@ -168,11 +171,33 @@ export function addToolsToServer( _meta: flattenedToolMeta, // Use flattened metadata for MCP protocol }; + // Register in the tool registry for listTools/callTool support + registry.register( + toolConfig.name, + handler, + toolSchema, + toolOutputSchema, + toolConfig.description, + toolConfig.annotations + ); + + // Wrap the handler to inject listTools and callTool into extra + const originalHandler = transformedHandler; + const wrappedHandler = async (args: any, extra: any) => { + const augmentedExtra = { + ...extra, + listTools: () => registry.list(), + callTool: (name: string, toolArgs: Record) => + registry.call(name, toolArgs, extra), + }; + return originalHandler(args, augmentedExtra); + }; + // server as any prevents infinite type recursion (server as any).registerTool( toolConfig.name, toolConfigFormatted, - transformedHandler + wrappedHandler ); }); diff --git a/packages/xmcp/src/types/tool.ts b/packages/xmcp/src/types/tool.ts index 8dd8c2588..534d73f9c 100644 --- a/packages/xmcp/src/types/tool.ts +++ b/packages/xmcp/src/types/tool.ts @@ -99,6 +99,44 @@ export interface ToolExtraArguments { [key: string]: unknown; } ) => Promise>; + + /** Returns cached metadata and JSON Schema for all registered tools */ + listTools: () => ToolInfo[]; + + /** Invoke another registered tool by name. Returns CallToolResult with isError on failure — never throws. */ + callTool: ( + toolName: string, + args: Record + ) => Promise; +} + +/** Metadata about a registered tool, returned by `extra.listTools()` */ +export interface ToolInfo { + /** Tool identifier (unique within server) */ + name: string; + /** Human-readable description */ + description: string; + /** JSON Schema describing accepted parameters */ + inputSchema: Record; + /** JSON Schema describing output structure (if defined) */ + outputSchema?: Record; + /** Tool annotations (readOnlyHint, destructiveHint, etc.) */ + annotations?: ToolAnnotations; +} + +/** The result of calling a tool via `extra.callTool()` */ +export interface CallToolResultCompat { + content: Array<{ + type: string; + text?: string; + data?: string; + mimeType?: string; + uri?: string; + [key: string]: unknown; + }>; + structuredContent?: Record; + isError?: boolean; + _meta?: Record; } export type InferSchema> = { From f6c739fc0d4c85de83f58cee6fe612ef338eec4f Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Thu, 26 Mar 2026 11:32:13 -0300 Subject: [PATCH 2/6] code mode working --- .../website/content/docs/guides/code-mode.mdx | 11 +- examples/code-mode-http/src/tools/execute.ts | 13 +- examples/code-mode-http/xmcp.config.ts | 5 + .../xmcp/src/runtime/utils/tool-registry.ts | 175 ++++++++++++++++++ pnpm-lock.yaml | 81 ++++---- 5 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 packages/xmcp/src/runtime/utils/tool-registry.ts diff --git a/apps/website/content/docs/guides/code-mode.mdx b/apps/website/content/docs/guides/code-mode.mdx index 940ab7bcc..cd58ba511 100644 --- a/apps/website/content/docs/guides/code-mode.mdx +++ b/apps/website/content/docs/guides/code-mode.mdx @@ -77,20 +77,23 @@ import type { ToolMetadata } from "xmcp"; export const schema = { toolName: z.string().describe("Name of the tool to execute"), - args: z.record(z.any()).describe("Arguments to pass to the tool"), + args: z + .string() + .describe('JSON string of arguments (e.g. \'{"name": "World"}\')'), }; export const metadata: ToolMetadata = { name: "execute", description: - "Execute any available tool by name. Use the search tool first to discover available tools and their required arguments.", + "Execute any available tool by name. Use the search tool first to discover available tools and their required arguments. Pass args as a JSON string.", annotations: { openWorldHint: true, }, }; export default async function execute({ toolName, args }, extra) { - return await extra.callTool(toolName, args); + const parsedArgs = JSON.parse(args); + return await extra.callTool(toolName, parsedArgs); } ``` @@ -162,7 +165,7 @@ Response: "tool": "execute", "args": { "toolName": "get-user", - "args": { "userId": "usr_123" } + "args": "{\"userId\": \"usr_123\"}" } } ``` diff --git a/examples/code-mode-http/src/tools/execute.ts b/examples/code-mode-http/src/tools/execute.ts index b545024e3..551f21842 100644 --- a/examples/code-mode-http/src/tools/execute.ts +++ b/examples/code-mode-http/src/tools/execute.ts @@ -3,19 +3,24 @@ import type { ToolMetadata, ToolExtraArguments } from "xmcp"; export const schema = { toolName: z.string().describe("Name of the tool to execute"), - args: z.record(z.any()).describe("Arguments to pass to the tool"), + args: z + .string() + .describe( + 'JSON string of arguments to pass to the tool (e.g. \'{"name": "World"}\')' + ), }; export const metadata: ToolMetadata = { name: "execute", description: - "Execute any available tool by name. Use the search tool first to discover tools and their required arguments.", + "Execute any available tool by name. Use the search tool first to discover tools and their required arguments. Pass args as a JSON string.", annotations: { openWorldHint: true }, }; export default async function execute( - { toolName, args }: { toolName: string; args: Record }, + { toolName, args }: { toolName: string; args: string }, extra: ToolExtraArguments ) { - return await extra.callTool(toolName, args); + const parsedArgs = JSON.parse(args); + return await extra.callTool(toolName, parsedArgs); } diff --git a/examples/code-mode-http/xmcp.config.ts b/examples/code-mode-http/xmcp.config.ts index 060a5943d..65f751fdf 100644 --- a/examples/code-mode-http/xmcp.config.ts +++ b/examples/code-mode-http/xmcp.config.ts @@ -2,6 +2,11 @@ import { XmcpConfig } from "xmcp"; const config: XmcpConfig = { http: true, + paths: { + tools: "./src/tools", + prompts: false, + resources: false, + }, typescript: { skipTypeCheck: true, }, diff --git a/packages/xmcp/src/runtime/utils/tool-registry.ts b/packages/xmcp/src/runtime/utils/tool-registry.ts new file mode 100644 index 000000000..875758286 --- /dev/null +++ b/packages/xmcp/src/runtime/utils/tool-registry.ts @@ -0,0 +1,175 @@ +import { z } from "zod"; +import { ZodRawShape } from "zod/v3"; +import type { ToolInfo, CallToolResultCompat, ToolAnnotations } from "@/types/tool"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types"; +import { transformToolHandler } from "./transformers/tool"; +import type { UserToolHandler } from "./transformers/tool"; + +// Use MCP SDK's built-in Zod-to-JSON-Schema conversion (handles both Zod v3 and v4) +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"; + +interface ToolRegistryEntry { + name: string; + description: string; + handler: UserToolHandler; + schema: ZodRawShape; + outputSchema?: ZodRawShape; + annotations?: ToolAnnotations; +} + +export class ToolRegistry { + private entries = new Map(); + private cachedInfos: ToolInfo[] | null = null; + + /** + * Register a tool in the registry. Called during server initialization. + */ + register( + name: string, + handler: UserToolHandler, + schema: ZodRawShape, + outputSchema: ZodRawShape | undefined, + description: string, + annotations?: ToolAnnotations + ): void { + this.entries.set(name, { + name, + description, + handler, + schema, + outputSchema, + annotations, + }); + // Invalidate cache when a new tool is registered + this.cachedInfos = null; + } + + /** + * Returns a cached, frozen array of ToolInfo for all registered tools. + * Serializes Zod schemas to JSON Schema on first call. + */ + list(): ToolInfo[] { + if (this.cachedInfos !== null) { + return this.cachedInfos; + } + + const infos: ToolInfo[] = []; + for (const entry of this.entries.values()) { + let inputSchema: Record = {}; + try { + const zodObj = z.object(entry.schema); + inputSchema = toJsonSchemaCompat(zodObj as any, { + strictUnions: true, + }) as Record; + } catch { + // Fallback: return empty schema if conversion fails + inputSchema = { type: "object", properties: {} }; + } + + let outputSchema: Record | undefined; + if (entry.outputSchema) { + try { + const zodObj = z.object(entry.outputSchema); + outputSchema = toJsonSchemaCompat(zodObj as any, { + strictUnions: true, + }) as Record; + } catch { + // Fallback: omit outputSchema if conversion fails + } + } + + infos.push({ + name: entry.name, + description: entry.description, + inputSchema, + outputSchema, + annotations: entry.annotations, + }); + } + + this.cachedInfos = Object.freeze(infos) as ToolInfo[]; + return this.cachedInfos; + } + + /** + * Invoke a registered tool by name. Never throws — always returns a CallToolResult. + * Validates args against the tool's Zod schema before invoking. + */ + async call( + toolName: string, + args: Record, + extra: RequestHandlerExtra + ): Promise { + const entry = this.entries.get(toolName); + + if (!entry) { + const available = Array.from(this.entries.keys()).join(", "); + return { + content: [ + { + type: "text", + text: `Tool "${toolName}" not found. Available tools: ${available}`, + }, + ], + isError: true, + }; + } + + // Check abort signal before proceeding + if (extra.signal?.aborted) { + return { + content: [ + { + type: "text", + text: `Tool "${toolName}" was aborted`, + }, + ], + isError: true, + }; + } + + // Validate args against tool's schema + const schemaObj = z.object(entry.schema); + const parseResult = schemaObj.safeParse(args); + if (!parseResult.success) { + return { + content: [ + { + type: "text", + text: `Validation error for tool "${toolName}": ${parseResult.error.message}`, + }, + ], + isError: true, + }; + } + + try { + // Wrap the user handler through transformToolHandler to get proper CallToolResult + const mcpHandler = transformToolHandler( + entry.handler, + undefined, + entry.outputSchema, + entry.name + ); + const result = await mcpHandler(parseResult.data as ZodRawShape, extra); + return result as CallToolResultCompat; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Tool "${toolName}" failed: ${message}`, + }, + ], + isError: true, + }; + } + } + + has(name: string): boolean { + return this.entries.has(name); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dc84658e..2cda9cc07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,10 +124,10 @@ importers: version: 0.178.1 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 1.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@vercel/speed-insights': specifier: ^1.3.1 - version: 1.3.1(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 1.3.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) ai: specifier: ^5.0.108 version: 5.0.108(zod@4.1.13) @@ -145,13 +145,13 @@ importers: version: 12.23.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) fumadocs-core: specifier: ^15.8.5 - version: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-mdx: specifier: ^12.0.3 - version: 12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-ui: specifier: ^15.8.5 - version: 15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.17) + version: 15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.17) gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -287,7 +287,7 @@ importers: dependencies: next: specifier: 16.1.7 - version: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: specifier: ^19.2.3 version: 19.2.3 @@ -364,10 +364,10 @@ importers: dependencies: better-auth: specifier: ^1.4.6 - version: 1.4.6(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.4.6(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: specifier: 16.1.7 - version: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) pg: specifier: ^8.16.3 version: 8.16.3 @@ -449,6 +449,15 @@ importers: specifier: ^4.62.0 version: 4.62.0(@cloudflare/workers-types@4.20260127.0) + examples/code-mode-http: + dependencies: + xmcp: + specifier: workspace:* + version: link:../../packages/xmcp + zod: + specifier: ^4.0.10 + version: 4.1.13 + examples/custom-bundler-config: dependencies: xmcp: @@ -12265,16 +12274,16 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.4 - '@vercel/analytics@1.6.1(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/analytics@1.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 '@vercel/oidc@3.0.5': {} - '@vercel/speed-insights@1.3.1(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/speed-insights@1.3.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.2)(lightningcss@1.30.2)(terser@5.44.1))': @@ -12697,7 +12706,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - better-auth@1.4.6(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + better-auth@1.4.6(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.0)) @@ -12713,7 +12722,7 @@ snapshots: nanostores: 1.1.0 zod: 4.1.13 optionalDependencies: - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -14125,7 +14134,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@formatjs/intl-localematcher': 0.6.2 '@orama/orama': 3.1.16 @@ -14148,21 +14157,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 lucide-react: 0.544.0(react@19.2.4) - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) react-router: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - supports-color - fumadocs-mdx@12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-mdx@12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.0.0 chokidar: 4.0.3 esbuild: 0.25.12 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) js-yaml: 4.1.1 lru-cache: 11.2.4 mdast-util-to-markdown: 2.1.2 @@ -14175,12 +14184,12 @@ snapshots: unist-util-visit: 5.0.0 zod: 4.1.13 optionalDependencies: - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - supports-color - fumadocs-ui@15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.17): + fumadocs-ui@15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.17): dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -14193,7 +14202,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 - fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) lodash.merge: 4.6.2 next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) postcss-selector-parser: 7.1.1 @@ -14204,7 +14213,7 @@ snapshots: tailwind-merge: 3.4.0 optionalDependencies: '@types/react': 19.2.14 - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwindcss: 4.1.17 transitivePeerDependencies: - '@mixedbread/sdk' @@ -15794,7 +15803,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 15.5.13 '@next/swc-darwin-x64': 15.5.13 @@ -15810,16 +15819,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.8 caniuse-lite: 1.0.30001767 postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.1.7 '@next/swc-darwin-x64': 16.1.7 @@ -15835,16 +15844,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.8 caniuse-lite: 1.0.30001767 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.4) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.7 '@next/swc-darwin-x64': 16.1.7 @@ -17171,19 +17180,17 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.4): dependencies: client-only: 0.0.1 - react: 19.2.3 + react: 19.2.4 optionalDependencies: '@babel/core': 7.28.5 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.4): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 - react: 19.2.4 - optionalDependencies: - '@babel/core': 7.28.5 + react: 19.2.3 sucrase@3.35.1: dependencies: From 49e9b0c114710ba4459bc62a49b389c015e13b69 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Thu, 26 Mar 2026 12:08:19 -0300 Subject: [PATCH 3/6] code mode w fuzzy search --- .../website/content/docs/guides/code-mode.mdx | 62 ++++++-- .../code-mode-http/src/tools/create-user.ts | 12 +- examples/code-mode-http/src/tools/get-user.ts | 7 +- .../code-mode-http/src/tools/list-users.ts | 6 +- examples/code-mode-http/src/tools/search.ts | 58 ++++++-- .../code-mode-http/src/utils/search-utils.ts | 138 ++++++++++++++++++ packages/xmcp/src/runtime/utils/tools.ts | 16 +- packages/xmcp/src/types/tool.ts | 2 + 8 files changed, 262 insertions(+), 39 deletions(-) create mode 100644 examples/code-mode-http/src/utils/search-utils.ts diff --git a/apps/website/content/docs/guides/code-mode.mdx b/apps/website/content/docs/guides/code-mode.mdx index cd58ba511..c253cef69 100644 --- a/apps/website/content/docs/guides/code-mode.mdx +++ b/apps/website/content/docs/guides/code-mode.mdx @@ -97,11 +97,11 @@ export default async function execute({ toolName, args }, extra) { } ``` -That's it. Your server now exposes just `search` and `execute` to agents, while all your other tools are accessible through them. +That's it. But there's one more step to get the full token savings. -### 3. Your actual tools +### 3. Mark tools as `internal` -Keep building tools as normal. They're still registered with xmcp — they're just accessed through the `execute` meta-tool instead of directly: +By default, all tools appear in MCP's `tools/list` response — agents see them upfront. To hide tools from `tools/list` while keeping them callable via `execute`, add `internal: true` to their annotations: ```typescript title="src/tools/get-user.ts" import { z } from "zod"; @@ -114,6 +114,11 @@ export const schema = { export const metadata: ToolMetadata = { name: "get-user", description: "Retrieve user profile information by ID", + annotations: { + readOnlyHint: true, + internal: true, + tags: ["users", "read"], + }, }; export default async function getUser({ userId }) { @@ -122,6 +127,21 @@ export default async function getUser({ userId }) { } ``` +Now agents connecting to your server only see `search` + `execute` in `tools/list` (~1,000 tokens). The internal tools are discoverable via `search` and callable via `execute`. + +### Tags for richer discovery + +Use `tags` in annotations to help agents filter tools by category: + +```typescript +annotations: { + internal: true, + tags: ["users", "admin"], +} +``` + +Then agents can search by tag: `search({ tag: "users" })` to find all user-related tools without scanning descriptions. + ## How agents interact with Code Mode An agent connecting to your server sees only 2 tools. Here's a typical interaction: @@ -211,27 +231,39 @@ export default jwtAuthMiddleware({ When an authenticated agent calls `execute`, the called tool receives the same `authInfo` — no additional setup needed. -## Advanced: filtering the tool list +## Advanced: smarter search + +The default `search` example includes TF-IDF scoring and fuzzy matching — it handles typos ("crete" matches "create") and ranks results by relevance. For most use cases this is sufficient. -You may want to exclude the meta-tools themselves from search results, or only expose a subset of tools: +For **true semantic search** (where "remove" finds "delete"), you need embeddings. You can swap in any embedding provider in your search tool: ```typescript title="src/tools/search.ts" +import { embed } from "your-embedding-lib"; // OpenAI, Cohere, local model, etc. + +// Pre-compute embeddings at startup +let toolEmbeddings: Map; + export default async function search({ query }, extra) { - const tools = extra.listTools(); - const hidden = new Set(["search", "execute"]); + if (!toolEmbeddings) { + toolEmbeddings = new Map(); + for (const tool of extra.listTools()) { + const vec = await embed(tool.description); + toolEmbeddings.set(tool.name, vec); + } + } - const matches = tools - .filter((t) => !hidden.has(t.name)) - .filter( - (t) => - t.name.toLowerCase().includes(query.toLowerCase()) || - t.description.toLowerCase().includes(query.toLowerCase()) - ); + const queryVec = await embed(query); + const scored = extra.listTools().map((t) => ({ + ...t, + score: cosineSimilarity(queryVec, toolEmbeddings.get(t.name)!), + })); - return JSON.stringify(matches, null, 2); + return JSON.stringify(scored.sort((a, b) => b.score - a.score).slice(0, 10)); } ``` +Since `search` is just a regular xmcp tool, you have full control over the search strategy. + ## Advanced: sandboxed code execution For the full Cloudflare-style experience where agents write JavaScript code (not just tool names), you can add a sandbox library like `quickjs-emscripten`: diff --git a/examples/code-mode-http/src/tools/create-user.ts b/examples/code-mode-http/src/tools/create-user.ts index dfd9ccdf8..013f2806e 100644 --- a/examples/code-mode-http/src/tools/create-user.ts +++ b/examples/code-mode-http/src/tools/create-user.ts @@ -9,7 +9,17 @@ export const schema = { export const metadata: ToolMetadata = { name: "create-user", description: "Create a new user account with name and email", - annotations: { destructiveHint: true }, + annotations: { + destructiveHint: true, + internal: true, + tags: ["users", "write"], + examples: [ + { + args: { name: "Alice", email: "alice@example.com" }, + description: "Create a new user", + }, + ], + }, }; export default async function createUser({ diff --git a/examples/code-mode-http/src/tools/get-user.ts b/examples/code-mode-http/src/tools/get-user.ts index fc87d2aef..2ab8d8ed2 100644 --- a/examples/code-mode-http/src/tools/get-user.ts +++ b/examples/code-mode-http/src/tools/get-user.ts @@ -8,7 +8,12 @@ export const schema = { export const metadata: ToolMetadata = { name: "get-user", description: "Retrieve user profile information by ID", - annotations: { readOnlyHint: true }, + annotations: { + readOnlyHint: true, + internal: true, + tags: ["users", "read"], + examples: [{ args: { userId: "1" }, description: "Get Alice's profile" }], + }, }; const users: Record = { diff --git a/examples/code-mode-http/src/tools/list-users.ts b/examples/code-mode-http/src/tools/list-users.ts index 7048b7b8b..4d751de1f 100644 --- a/examples/code-mode-http/src/tools/list-users.ts +++ b/examples/code-mode-http/src/tools/list-users.ts @@ -3,7 +3,11 @@ import type { ToolMetadata } from "xmcp"; export const metadata: ToolMetadata = { name: "list-users", description: "List all users in the system", - annotations: { readOnlyHint: true }, + annotations: { + readOnlyHint: true, + internal: true, + tags: ["users", "read"], + }, }; const users = [ diff --git a/examples/code-mode-http/src/tools/search.ts b/examples/code-mode-http/src/tools/search.ts index 7c8c20718..e503bdc52 100644 --- a/examples/code-mode-http/src/tools/search.ts +++ b/examples/code-mode-http/src/tools/search.ts @@ -1,33 +1,61 @@ import { z } from "zod"; import type { ToolMetadata, ToolExtraArguments } from "xmcp"; +import { buildIndex, searchTools, type SearchIndex } from "../utils/search-utils"; export const schema = { - query: z.string().describe("Search query to filter available tools"), + query: z + .string() + .optional() + .describe("Search query to filter tools by name or description"), + tag: z.string().optional().describe("Filter tools by tag (e.g. 'users')"), }; export const metadata: ToolMetadata = { name: "search", description: - "Search available tools by name or description. Returns matching tools with their input schemas so you know how to call them.", + "Search available tools by name, description, or tag. Returns matching tools ranked by relevance with their input schemas so you know how to call them via the execute tool.", annotations: { readOnlyHint: true }, }; const META_TOOLS = new Set(["search", "execute"]); +let cachedIndex: SearchIndex | null = null; export default async function search( - { query }: { query: string }, + { query, tag }: { query?: string; tag?: string }, extra: ToolExtraArguments ) { - const tools = extra.listTools(); - const q = query.toLowerCase(); - - const matches = tools - .filter((t) => !META_TOOLS.has(t.name)) - .filter( - (t) => - t.name.toLowerCase().includes(q) || - t.description.toLowerCase().includes(q) - ); - - return JSON.stringify(matches, null, 2); + let tools = extra.listTools().filter((t) => !META_TOOLS.has(t.name)); + + // Tag filter (pre-filter before scoring) + if (tag) { + tools = tools.filter((t) => { + const tags = (t.annotations as any)?.tags as string[] | undefined; + return tags?.includes(tag); + }); + } + + // If no query, return all (tag-filtered) tools + if (!query) { + return JSON.stringify(tools, null, 2); + } + + // Build index lazily (tools are cached, so this is stable) + if (!cachedIndex) { + cachedIndex = buildIndex(extra.listTools().filter((t) => !META_TOOLS.has(t.name))); + } + + // Score and rank results + const scored = searchTools(cachedIndex, query); + + // If tag filter was used, intersect with tag results + const tagNames = tag ? new Set(tools.map((t) => t.name)) : null; + const results = tagNames + ? scored.filter((r) => tagNames.has(r.tool.name)) + : scored; + + return JSON.stringify( + results.map((r) => ({ ...r.tool, _relevance: Math.round(r.score * 100) / 100 })), + null, + 2 + ); } diff --git a/examples/code-mode-http/src/utils/search-utils.ts b/examples/code-mode-http/src/utils/search-utils.ts new file mode 100644 index 000000000..133b16a33 --- /dev/null +++ b/examples/code-mode-http/src/utils/search-utils.ts @@ -0,0 +1,138 @@ +import type { ToolInfo } from "xmcp"; + +export interface ScoredResult { + tool: ToolInfo; + score: number; +} + +export interface SearchIndex { + tools: ToolInfo[]; + idf: Map; + toolTerms: Map>; +} + +/** Tokenize text into lowercase terms */ +function tokenize(text: string): string[] { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .split(/\s+/) + .filter((t) => t.length > 1); +} + +/** Build a TF-IDF index from tool names and descriptions */ +export function buildIndex(tools: ToolInfo[]): SearchIndex { + const docCount = tools.length; + const docFreq = new Map(); + const toolTerms = new Map>(); + + for (const tool of tools) { + const text = `${tool.name} ${tool.description} ${(tool.annotations as any)?.tags?.join(" ") ?? ""}`; + const terms = tokenize(text); + const tf = new Map(); + for (const term of terms) { + tf.set(term, (tf.get(term) ?? 0) + 1); + } + // Normalize TF + const maxFreq = Math.max(...tf.values(), 1); + const normalized = new Map(); + for (const [term, freq] of tf) { + normalized.set(term, freq / maxFreq); + } + toolTerms.set(tool.name, normalized); + + const seen = new Set(); + for (const term of terms) { + if (!seen.has(term)) { + docFreq.set(term, (docFreq.get(term) ?? 0) + 1); + seen.add(term); + } + } + } + + const idf = new Map(); + for (const [term, freq] of docFreq) { + idf.set(term, Math.log((docCount + 1) / (freq + 1)) + 1); + } + + return { tools, idf, toolTerms }; +} + +/** Levenshtein distance between two strings */ +function levenshtein(a: string, b: string): number { + const m = a.length, + n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => + Array(n + 1).fill(0) + ); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +/** Best fuzzy match score (0-1) of a query term against a tool's terms */ +function fuzzyScore(queryTerm: string, toolText: string): number { + const toolTerms = tokenize(toolText); + let best = 0; + for (const t of toolTerms) { + const maxLen = Math.max(queryTerm.length, t.length); + if (maxLen === 0) continue; + const dist = levenshtein(queryTerm, t); + const sim = 1 - dist / maxLen; + if (sim > best) best = sim; + } + return best; +} + +/** Search tools by query, returning scored and sorted results */ +export function searchTools( + index: SearchIndex, + query: string, + minScore = 0.15 +): ScoredResult[] { + const queryTerms = tokenize(query); + if (queryTerms.length === 0) return index.tools.map((t) => ({ tool: t, score: 1 })); + + const results: ScoredResult[] = []; + + for (const tool of index.tools) { + const toolTf = index.toolTerms.get(tool.name); + if (!toolTf) continue; + + const toolText = `${tool.name} ${tool.description}`; + + // TF-IDF score + let tfidfScore = 0; + for (const qt of queryTerms) { + const tf = toolTf.get(qt) ?? 0; + const idf = index.idf.get(qt) ?? 0; + tfidfScore += tf * idf; + } + // Normalize by query length + tfidfScore = tfidfScore / queryTerms.length; + + // Fuzzy score (average best match per query term) + let fuzzy = 0; + for (const qt of queryTerms) { + fuzzy += fuzzyScore(qt, toolText); + } + fuzzy = fuzzy / queryTerms.length; + + // Combined: weight TF-IDF higher, fuzzy as fallback + const score = tfidfScore * 0.6 + fuzzy * 0.4; + + if (score >= minScore) { + results.push({ tool, score }); + } + } + + return results.sort((a, b) => b.score - a.score); +} diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index 5b4f66190..9af736d1b 100644 --- a/packages/xmcp/src/runtime/utils/tools.ts +++ b/packages/xmcp/src/runtime/utils/tools.ts @@ -193,12 +193,16 @@ export function addToolsToServer( return originalHandler(args, augmentedExtra); }; - // server as any prevents infinite type recursion - (server as any).registerTool( - toolConfig.name, - toolConfigFormatted, - wrappedHandler - ); + // Only register with MCP server if not marked as internal. + // Internal tools are still in the ToolRegistry (accessible via callTool/listTools). + if (!toolConfig.annotations?.internal) { + // server as any prevents infinite type recursion + (server as any).registerTool( + toolConfig.name, + toolConfigFormatted, + wrappedHandler + ); + } }); return server; diff --git a/packages/xmcp/src/types/tool.ts b/packages/xmcp/src/types/tool.ts index 534d73f9c..a135ca315 100644 --- a/packages/xmcp/src/types/tool.ts +++ b/packages/xmcp/src/types/tool.ts @@ -13,6 +13,8 @@ export interface ToolAnnotations { idempotentHint?: boolean; /** If true, tool interacts with external entities */ openWorldHint?: boolean; + /** If true, tool is hidden from MCP tools/list but accessible via extra.callTool() and extra.listTools() */ + internal?: boolean; [key: string]: any; } From 4a3c0e947d158110f5a3d6f0a1048886f71908e6 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Thu, 26 Mar 2026 12:34:24 -0300 Subject: [PATCH 4/6] typos --- .../website/content/docs/guides/code-mode.mdx | 76 +++++-------------- 1 file changed, 17 insertions(+), 59 deletions(-) diff --git a/apps/website/content/docs/guides/code-mode.mdx b/apps/website/content/docs/guides/code-mode.mdx index c253cef69..45cac425c 100644 --- a/apps/website/content/docs/guides/code-mode.mdx +++ b/apps/website/content/docs/guides/code-mode.mdx @@ -1,9 +1,9 @@ --- title: "Code Mode" metadataTitle: "Code Mode Pattern Guide | xmcp Documentation" -publishedAt: "2026-03-25" +publishedAt: "2026-03-26" summary: "Reduce tool context bloat by exposing your entire API surface through just 2 meta-tools." -description: "Learn how to implement the Code Mode pattern in xmcp — replace hundreds of individual MCP tools with search and execute meta-tools for massive token savings." +description: "Learn how to implement the Code Mode pattern in xmcp, replace hundreds of individual MCP tools with search and execute meta-tools for massive token savings." --- ## Overview @@ -12,8 +12,8 @@ MCP servers with many tools create a problem: every tool definition (name, descr Code Mode solves this with a simple pattern: instead of exposing all tools directly, expose just **2 meta-tools**: -- **`search`** — Agents query tool metadata to discover what's available -- **`execute`** — Agents invoke any tool by name +- **`search`**: Agents query tool metadata to discover what's available +- **`execute`**: Agents invoke any tool by name This reduces the tool definition footprint from tens of thousands of tokens to under 1,000 — a 99%+ reduction. The pattern was [popularized by Cloudflare](https://blog.cloudflare.com/code-mode-mcp/) for their 2,500+ endpoint API. @@ -27,16 +27,16 @@ This reduces the tool definition footprint from tens of thousands of tokens to u xmcp provides two primitives in every tool handler's `extra` parameter: -- **`extra.listTools()`** — Returns metadata and JSON Schema for all registered tools -- **`extra.callTool(name, args)`** — Invokes another tool by name, validates args, and returns the result +- **`extra.listTools()`**: Returns metadata and JSON Schema for all registered tools +- **`extra.callTool(name, args)`**: Invokes another tool by name, validates args, and returns the result -You use these to build your own `search` and `execute` tools — no plugins or special configuration needed. +You use these to build your own `search` and `execute` tools. No plugins or special configuration needed. ## Quickstart Create two tool files in your xmcp project: -### 1. The search tool +### The search tool ```typescript title="src/tools/search.ts" import { z } from "zod"; @@ -50,9 +50,6 @@ export const metadata: ToolMetadata = { name: "search", description: "Search available tools by name or description. Returns matching tools with their input schemas so you know how to call them.", - annotations: { - readOnlyHint: true, - }, }; export default async function search({ query }, extra) { @@ -69,7 +66,7 @@ export default async function search({ query }, extra) { } ``` -### 2. The execute tool +### The execute tool ```typescript title="src/tools/execute.ts" import { z } from "zod"; @@ -97,11 +94,9 @@ export default async function execute({ toolName, args }, extra) { } ``` -That's it. But there's one more step to get the full token savings. +### Tools as `internal` -### 3. Mark tools as `internal` - -By default, all tools appear in MCP's `tools/list` response — agents see them upfront. To hide tools from `tools/list` while keeping them callable via `execute`, add `internal: true` to their annotations: +By default, all tools appear in MCP's `tools/list` response. When connecting to a server, clients see them upfront. To hide tools from `tools/list` while keeping them callable via `execute`, add `internal: true` to their annotations: ```typescript title="src/tools/get-user.ts" import { z } from "zod"; @@ -115,9 +110,7 @@ export const metadata: ToolMetadata = { name: "get-user", description: "Retrieve user profile information by ID", annotations: { - readOnlyHint: true, internal: true, - tags: ["users", "read"], }, }; @@ -229,11 +222,11 @@ export default jwtAuthMiddleware({ }); ``` -When an authenticated agent calls `execute`, the called tool receives the same `authInfo` — no additional setup needed. +When an authenticated agent calls `execute`, the called tool receives the same `authInfo`. -## Advanced: smarter search +## Smarter search -The default `search` example includes TF-IDF scoring and fuzzy matching — it handles typos ("crete" matches "create") and ranks results by relevance. For most use cases this is sufficient. +The default `search` example includes TF-IDF scoring and fuzzy matching. It handles typos ("crete" matches "create") and ranks results by relevance. For most use cases this is sufficient. For **true semantic search** (where "remove" finds "delete"), you need embeddings. You can swap in any embedding provider in your search tool: @@ -264,43 +257,8 @@ export default async function search({ query }, extra) { Since `search` is just a regular xmcp tool, you have full control over the search strategy. -## Advanced: sandboxed code execution - -For the full Cloudflare-style experience where agents write JavaScript code (not just tool names), you can add a sandbox library like `quickjs-emscripten`: - -```typescript title="src/tools/execute.ts" -import { getQuickJS } from "quickjs-emscripten"; - -export default async function execute({ code }, extra) { - const QuickJS = await getQuickJS(); - const vm = QuickJS.newContext(); - - // Inject tools as callable functions in the sandbox - const toolsHandle = vm.newObject(); - for (const tool of extra.listTools()) { - const fn = vm.newFunction(tool.name, (...handles) => { - const args = JSON.parse(vm.getString(handles[0])); - return extra.callTool(tool.name, args); - }); - vm.setProp(toolsHandle, tool.name, fn); - fn.dispose(); - } - vm.setProp(vm.global, "tools", toolsHandle); - toolsHandle.dispose(); - - const result = vm.evalCode(code); - const output = vm.getString(result.value); - result.value.dispose(); - vm.dispose(); - - return output; -} -``` - -This is an application-level choice — xmcp provides the primitives, you decide the execution model. - ## References -- [Cloudflare Code Mode announcement](https://blog.cloudflare.com/code-mode-mcp/) — The original pattern -- [Tools documentation](/docs/core-concepts/tools) — How xmcp tools work -- [Authentication guide](/docs/guides/authentication) — Combining Code Mode with auth +- [Cloudflare Code Mode announcement](https://blog.cloudflare.com/code-mode-mcp/): The original pattern +- [Tools documentation](/docs/core-concepts/tools): How xmcp tools work +- [Authentication guide](/docs/guides/authentication): Combining Code Mode with auth From 61e1febeed81a0ed98d39430b57e9a758c15e148 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Thu, 26 Mar 2026 12:43:11 -0300 Subject: [PATCH 5/6] Update pnpm-lock.yaml --- pnpm-lock.yaml | 50 -------------------------------------------------- 1 file changed, 50 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6ad2837e..b50eb2a6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15601,26 +15601,6 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) -<<<<<<< HEAD - next@15.5.13(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - '@next/env': 15.5.13 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001767 - postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) - optionalDependencies: - '@next/swc-darwin-arm64': 15.5.13 - '@next/swc-darwin-x64': 15.5.13 - '@next/swc-linux-arm64-gnu': 15.5.13 - '@next/swc-linux-arm64-musl': 15.5.13 - '@next/swc-linux-x64-gnu': 15.5.13 - '@next/swc-linux-x64-musl': 15.5.13 - '@next/swc-win32-arm64-msvc': 15.5.13 - '@next/swc-win32-x64-msvc': 15.5.13 -======= next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.7 @@ -15640,36 +15620,6 @@ snapshots: '@next/swc-linux-x64-musl': 16.1.7 '@next/swc-win32-arm64-msvc': 16.1.7 '@next/swc-win32-x64-msvc': 16.1.7 ->>>>>>> canary - '@opentelemetry/api': 1.9.0 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - -<<<<<<< HEAD - next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): -======= - next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): ->>>>>>> canary - dependencies: - '@next/env': 16.1.7 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.8 - caniuse-lite: 1.0.30001767 - postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) - optionalDependencies: - '@next/swc-darwin-arm64': 16.1.7 - '@next/swc-darwin-x64': 16.1.7 - '@next/swc-linux-arm64-gnu': 16.1.7 - '@next/swc-linux-arm64-musl': 16.1.7 - '@next/swc-linux-x64-gnu': 16.1.7 - '@next/swc-linux-x64-musl': 16.1.7 - '@next/swc-win32-arm64-msvc': 16.1.7 - '@next/swc-win32-x64-msvc': 16.1.7 '@opentelemetry/api': 1.9.0 sharp: 0.34.5 transitivePeerDependencies: From 9dfcfad77f539dd6f45b135e7b2a0ac2c756141a Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Thu, 26 Mar 2026 12:57:22 -0300 Subject: [PATCH 6/6] greptile fix --- apps/website/content/docs/guides/code-mode.mdx | 7 ++++++- examples/code-mode-http/src/tools/execute.ts | 7 ++++++- packages/xmcp/src/runtime/utils/tool-registry.ts | 11 +++-------- packages/xmcp/src/runtime/utils/tools.ts | 6 +++--- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/website/content/docs/guides/code-mode.mdx b/apps/website/content/docs/guides/code-mode.mdx index 45cac425c..30760b666 100644 --- a/apps/website/content/docs/guides/code-mode.mdx +++ b/apps/website/content/docs/guides/code-mode.mdx @@ -89,7 +89,12 @@ export const metadata: ToolMetadata = { }; export default async function execute({ toolName, args }, extra) { - const parsedArgs = JSON.parse(args); + let parsedArgs; + try { + parsedArgs = JSON.parse(args); + } catch { + return { content: [{ type: "text", text: "Invalid JSON in args" }], isError: true }; + } return await extra.callTool(toolName, parsedArgs); } ``` diff --git a/examples/code-mode-http/src/tools/execute.ts b/examples/code-mode-http/src/tools/execute.ts index 551f21842..ae8ce22c5 100644 --- a/examples/code-mode-http/src/tools/execute.ts +++ b/examples/code-mode-http/src/tools/execute.ts @@ -21,6 +21,11 @@ export default async function execute( { toolName, args }: { toolName: string; args: string }, extra: ToolExtraArguments ) { - const parsedArgs = JSON.parse(args); + let parsedArgs; + try { + parsedArgs = JSON.parse(args); + } catch { + return { content: [{ type: "text", text: "Invalid JSON in args" }], isError: true }; + } return await extra.callTool(toolName, parsedArgs); } diff --git a/packages/xmcp/src/runtime/utils/tool-registry.ts b/packages/xmcp/src/runtime/utils/tool-registry.ts index 875758286..fa38c7875 100644 --- a/packages/xmcp/src/runtime/utils/tool-registry.ts +++ b/packages/xmcp/src/runtime/utils/tool-registry.ts @@ -13,6 +13,7 @@ interface ToolRegistryEntry { name: string; description: string; handler: UserToolHandler; + mcpHandler: ReturnType; schema: ZodRawShape; outputSchema?: ZodRawShape; annotations?: ToolAnnotations; @@ -37,6 +38,7 @@ export class ToolRegistry { name, description, handler, + mcpHandler: transformToolHandler(handler, undefined, outputSchema, name), schema, outputSchema, annotations, @@ -145,14 +147,7 @@ export class ToolRegistry { } try { - // Wrap the user handler through transformToolHandler to get proper CallToolResult - const mcpHandler = transformToolHandler( - entry.handler, - undefined, - entry.outputSchema, - entry.name - ); - const result = await mcpHandler(parseResult.data as ZodRawShape, extra); + const result = await entry.mcpHandler(parseResult.data as ZodRawShape, extra); return result as CallToolResultCompat; } catch (error: unknown) { const message = diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index 9af736d1b..c0571e8ba 100644 --- a/packages/xmcp/src/runtime/utils/tools.ts +++ b/packages/xmcp/src/runtime/utils/tools.ts @@ -184,13 +184,13 @@ export function addToolsToServer( // Wrap the handler to inject listTools and callTool into extra const originalHandler = transformedHandler; const wrappedHandler = async (args: any, extra: any) => { - const augmentedExtra = { + const extraTools: any = { ...extra, listTools: () => registry.list(), callTool: (name: string, toolArgs: Record) => - registry.call(name, toolArgs, extra), + registry.call(name, toolArgs, extraTools), }; - return originalHandler(args, augmentedExtra); + return originalHandler(args, extraTools); }; // Only register with MCP server if not marked as internal.