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..614df4437 --- /dev/null +++ b/apps/website/content/docs/guides/code-mode.mdx @@ -0,0 +1,303 @@ +--- +title: "Code Mode" +metadataTitle: "Code Mode Pattern Guide | xmcp Documentation" +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." +--- + +## 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: + +### 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.", +}; + +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); +} +``` + +### 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 + .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. Pass args as a JSON string.", + annotations: { + openWorldHint: true, + }, +}; + +export default async function execute({ toolName, args }, extra) { + 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); +} +``` + +### Tools as `internal` + +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"; +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: { + internal: true, + }, +}; + +export default async function getUser({ userId }) { + const user = await db.users.findById(userId); + return JSON.stringify(user); +} +``` + +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: + +**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`. + +## 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. + +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) { + if (!toolEmbeddings) { + toolEmbeddings = new Map(); + for (const tool of extra.listTools()) { + const vec = await embed(tool.description); + toolEmbeddings.set(tool.name, vec); + } + } + + const queryVec = await embed(query); + const scored = extra.listTools().map((t) => ({ + ...t, + score: cosineSimilarity(queryVec, toolEmbeddings.get(t.name)!), + })); + + 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. + +## Sandboxed code execution + +For the full Cloudflare-style Code Mode where agents write JavaScript code that runs in an isolated sandbox, use the [`@xmcp-dev/sandbox`](/docs/integrations/sandbox) plugin. It runs agent code in [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) Firecracker microVMs with OS-level isolation, full async/await, and no native dependencies. + +```bash +pnpm add @xmcp-dev/sandbox +``` + +```typescript title="src/tools/search.ts" +import { searchTool } from "@xmcp-dev/sandbox"; +const search = searchTool({ url: "https://api.example.com/openapi.json" }); +export const schema = search.schema; +export const metadata = search.metadata; +export default search.handler; +``` + +```typescript title="src/tools/execute.ts" +import { executeTool } from "@xmcp-dev/sandbox"; +const execute = executeTool({ + url: "https://api.example.com", + env: ["API_KEY"], + networkPolicy: { allow: ["api.example.com"] }, +}); +export const schema = execute.schema; +export const metadata = execute.metadata; +export default execute.handler; +``` + +The search tool includes built-in helpers by default. Agents can use `search("pet")` for fuzzy search, `filter({ method: "GET", tag: "pet" })` for structured filtering, or the raw `spec` global for custom queries. + +Two files, fully configured. Requires a Vercel account. See the [full sandbox documentation](/docs/integrations/sandbox) for the API reference, search helpers, custom implementations, and pricing. + +**Example:** `examples/openapi-code-mode` -- Full OpenAPI Code Mode with Petstore API + +## 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/apps/website/content/docs/integrations/meta.json b/apps/website/content/docs/integrations/meta.json index 8aa7f6113..b0e710852 100644 --- a/apps/website/content/docs/integrations/meta.json +++ b/apps/website/content/docs/integrations/meta.json @@ -1,5 +1,6 @@ { "title": "Integrations", + "pages": ["sandbox", "polar", "x402", "auth0", "clerk", "better-auth", "workos"], "defaultOpen": true, "root": true } diff --git a/apps/website/content/docs/integrations/sandbox.mdx b/apps/website/content/docs/integrations/sandbox.mdx new file mode 100644 index 000000000..b30db36fa --- /dev/null +++ b/apps/website/content/docs/integrations/sandbox.mdx @@ -0,0 +1,370 @@ +--- +title: "Sandbox" +metadataTitle: "Sandboxed Code Execution | xmcp Documentation" +publishedAt: "2026-03-30" +summary: "Execute agent-provided JavaScript code safely in an isolated Vercel Sandbox" +description: "The @xmcp-dev/sandbox plugin runs untrusted code in Vercel Sandbox Firecracker microVMs with OS-level isolation, full async/await, and no native dependencies." +--- + +## Overview + +The `@xmcp-dev/sandbox` plugin lets you execute agent-provided JavaScript code in an isolated [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) environment. Each execution runs in its own Firecracker microVM with a separate filesystem, network stack, and process space. + +This enables the [Code Mode pattern](/docs/guides/code-mode) where agents write JavaScript to query API specs and chain HTTP calls. The plugin is general-purpose and can be used in any tool for sandboxed evaluation. + +**Requires a Vercel account.** Authentication is automatic when deployed on Vercel. For local development, run `vercel link` and `vercel env pull`. + +## Installation + +```bash +pnpm add @xmcp-dev/sandbox +``` + +## Quick setup (factory functions) + +The fastest way to add Code Mode. Two files, ~7 lines each: + +```typescript title="src/tools/search.ts" +import { searchTool } from "@xmcp-dev/sandbox"; + +const search = searchTool({ + url: "https://petstore3.swagger.io/api/v3/openapi.json", +}); + +export const schema = search.schema; +export const metadata = search.metadata; +export default search.handler; +``` + +```typescript title="src/tools/execute.ts" +import { executeTool } from "@xmcp-dev/sandbox"; + +const execute = executeTool({ + url: "https://petstore3.swagger.io/api/v3", + env: ["API_KEY"], + networkPolicy: { allow: ["petstore3.swagger.io"] }, +}); + +export const schema = execute.schema; +export const metadata = execute.metadata; +export default execute.handler; +``` + +That's it. Agents can now search the API spec and execute calls. + +### `searchTool(config)` + +Creates a search tool that loads an OpenAPI spec and lets agents query it with JavaScript. + +- `url` (required) -- URL of the OpenAPI spec +- `timeoutMs` -- Execution timeout (default: 10000ms) +- `networkPolicy` -- Network restrictions (default: `"deny-all"` since search is data-only) +- `helpers` -- Inject built-in search functions (default: `true`). See [Search helpers](#search-helpers). + +### `executeTool(config)` + +Creates an execute tool that lets agents make API calls with `fetch()` inside the sandbox. + +- `url` (required) -- API base URL, injected as the `url` global +- `env` -- Host env var names to forward to the sandbox (e.g. `["API_KEY"]`) +- `networkPolicy` -- Network restrictions (default: `"allow-all"`) +- `timeoutMs` -- Execution timeout (default: 30000ms) +- `packages` -- npm packages to install before execution + +### Search helpers + +When `helpers: true` (the default), the search tool injects built-in functions into the sandbox alongside the raw `spec` global. Agents can use whichever approach fits best: + +**`search(query)`** -- Fuzzy text search across paths, summaries, descriptions, tags, and operationIds. Returns results ranked by relevance. Handles typos (e.g. "crete" matches "create"). + +```javascript +// Agent code +return search("pet") +// → [{ path: "/pet", method: "POST", summary: "Add a new pet", ... }, ...] +``` + +**`filter({ method?, tag?, path? })`** -- Structured filtering by HTTP method, tag name, or path substring. + +```javascript +// Agent code +return filter({ method: "GET", tag: "pet" }) +// → only GET endpoints tagged "pet" + +return filter({ path: "find" }) +// → endpoints with "find" in the path +``` + +**`endpoints`** -- Pre-parsed array of all endpoints from the spec. + +```javascript +// Agent code +return endpoints.slice(0, 5) +// Each: { path, method, summary, description, tags, parameters, operationId } +``` + +**Raw `spec`** -- The full OpenAPI spec as a JSON string, always available for custom queries. + +```javascript +// Agent code +const s = JSON.parse(spec); +return s.info +``` + +To disable helpers and only expose the raw `spec`: `searchTool({ url: "...", helpers: false })`. + +## Custom implementation (raw API) + +For full control over the tool handler, use `runInSandbox` directly: + +```typescript title="src/tools/search.ts" +import { z } from "zod"; +import type { ToolMetadata } from "xmcp"; +import { runInSandbox } from "@xmcp-dev/sandbox"; + +export const schema = { + code: z.string().describe("JavaScript code to query the API spec"), +}; + +export const metadata: ToolMetadata = { + name: "search", + description: "Search API endpoints with JavaScript", +}; + +export default async function search({ code }) { + const spec = await fetch("https://api.example.com/openapi.json").then(r => r.text()); + + const result = await runInSandbox(code, { + globals: { spec }, + timeoutMs: 5000, + networkPolicy: "deny-all", + }); + + if (!result.success) { + return { content: [{ type: "text", text: result.error }], isError: true }; + } + return JSON.stringify(result.data, null, 2); +} +``` + +This gives you full control over spec loading, error handling, result transformation, and any custom logic. + +## API + +### `runInSandbox(code, options?)` + +Executes a JavaScript code string in an isolated Vercel Sandbox microVM. Never throws. + +```typescript +import { runInSandbox } from "@xmcp-dev/sandbox"; + +const result = await runInSandbox("return 1 + 1", { timeoutMs: 5000 }); +// { success: true, data: 2 } +``` + +**Parameters:** + +- `code` (string) -- JavaScript code to execute. Use `return` to produce a value. Agent code has access to `fetch()` for HTTP requests. +- `options` (SandboxOptions, optional): + - `globals` -- Record of named string values injected as constants + - `timeoutMs` -- Maximum execution time (default: 30000ms) + - `env` -- Environment variables injected into `process.env` inside the VM. Use for secrets. + - `networkPolicy` -- Network restrictions: `"allow-all"` (default), `"deny-all"`, or `{ allow: ["domain.com"] }` + - `packages` -- npm packages to install before execution (e.g. `["cheerio", "yaml"]`) + - `snapshotId` -- Resume from a snapshot instead of a fresh VM. See `createSnapshot()`. + - `runtime` -- VM runtime: `"node24"` (default), `"node22"`, or `"python3.13"` + +**Returns:** `SandboxResult` + +```typescript +interface SandboxOptions { + globals?: Record; + timeoutMs?: number; + env?: Record; + networkPolicy?: "allow-all" | "deny-all" | { allow?: string[] | Record }; + packages?: string[]; + snapshotId?: string; + runtime?: string; +} + +interface SandboxResult { + success: boolean; + data?: unknown; // Return value (if success) + error?: string; // Error message (if failure) +} +``` + +## Globals + +Inject named values as strings. Agent code accesses them as `const` variables and parses JSON values with `JSON.parse()`. + +```typescript +const result = await runInSandbox(code, { + globals: { + spec: myOpenAPISpecJson, + url: "https://api.example.com", + }, + env: { API_KEY: process.env.API_KEY }, +}); +``` + +Inside agent code: + +```javascript +const s = JSON.parse(spec); +const res = await fetch(url + "/users", { + headers: { Authorization: "Bearer " + process.env.API_KEY }, +}); +return await res.json(); +``` + +Globals are value-only (strings). Use `env` for secrets (they go into `process.env` inside the VM instead of the script source). Agent code uses `fetch()` directly inside the microVM for HTTP calls. + +## Environment variables + +Use `env` to inject secrets into `process.env` inside the VM. This is more secure than passing secrets as globals (which appear as literal strings in the script source). + +```typescript +const result = await runInSandbox(code, { + globals: { url: "https://api.example.com" }, + env: { API_KEY: process.env.API_KEY }, +}); +``` + +Inside agent code: +```javascript +const res = await fetch(url + "/data", { + headers: { Authorization: "Bearer " + process.env.API_KEY }, +}); +return await res.json(); +``` + +## Network policy + +Restrict which domains agent code can reach from inside the VM. + +```typescript +// Only allow calls to your API (default is "allow-all") +const result = await runInSandbox(code, { + globals: { url: "https://api.example.com" }, + networkPolicy: { allow: ["api.example.com"] }, +}); + +// Block all network access (data-only sandbox) +const result = await runInSandbox(code, { + globals: { spec: specJson }, + networkPolicy: "deny-all", +}); +``` + +## Installing packages + +Pre-install npm packages before the agent's code runs. + +```typescript +const result = await runInSandbox(code, { + packages: ["cheerio", "yaml"], + globals: { htmlContent: myHtml }, +}); +``` + +Inside agent code: +```javascript +const cheerio = require("cheerio"); +const $ = cheerio.load(htmlContent); +return $("h1").text(); +``` + +## Snapshots + +Create a reusable snapshot with pre-installed packages for faster repeated executions. + +```typescript +import { createSnapshot, runInSandbox } from "@xmcp-dev/sandbox"; + +// Create once (e.g., at server startup) +const snapshotId = await createSnapshot({ packages: ["yaml", "lodash"] }); + +// Use for every call (skips npm install, starts faster) +const result = await runInSandbox(code, { + snapshotId, + globals: { data: myData }, +}); +``` + +Snapshots expire after 30 days on Vercel by default. + +## Isolation and security + +Each `runInSandbox` call creates a new Firecracker microVM on Vercel's infrastructure with: + +- **Separate filesystem** -- agent code cannot access the host's files +- **Separate process space** -- agent code cannot access host environment variables or processes +- **Full network access** -- agent code can call `fetch()` (configurable via Vercel Sandbox network policies) +- **Node.js 24** -- full standard library available inside the VM + +Agent code cannot: +- Read or write files on the host +- Access `process.env` from the host (only what's injected via globals) +- Import host modules +- Affect the host process in any way + +## Timeouts + +```typescript +const result = await runInSandbox("while(true){}", { timeoutMs: 1000 }); +// { success: false, error: "Execution timed out after 1000ms" } +``` + +## Error handling + +`runInSandbox` never throws. All errors are returned in the result: + +```typescript +// Syntax error +await runInSandbox("{{{", {}); +// { success: false, error: "Unexpected token '{'" } + +// Runtime error +await runInSandbox("throw new Error('boom')", {}); +// { success: false, error: "boom" } + +// Missing Vercel auth +await runInSandbox("return 1", {}); +// { success: false, error: "Vercel Sandbox requires authentication. Run 'vercel link'..." } +``` + +## Vercel auth setup + +### Production (on Vercel) + +Authentication is automatic. No setup needed. + +### Local development + +```bash +# Link your project to Vercel +vercel link + +# Pull development credentials (expires after 12 hours) +vercel env pull +``` + +## Pricing + +Vercel Sandbox usage is billed through your Vercel account: + +- **Free tier (Hobby):** 5 free CPU hours/month, up to 10 concurrent sandboxes +- **Pro:** $0.128/hour active CPU, charged against $20/month credit +- **Quick execution (~2 min):** ~$0.01 + +See [Vercel Sandbox pricing](https://vercel.com/docs/vercel-sandbox/pricing) for details. + +## Examples + +- [`examples/openapi-code-mode`](/docs/guides/code-mode) -- Full OpenAPI Code Mode with Petstore API (search + execute in Vercel Sandbox) + +## References + +- [Code Mode guide](/docs/guides/code-mode) -- The pattern that uses this plugin +- [Cloudflare Code Mode](https://blog.cloudflare.com/code-mode-mcp/) -- The original inspiration +- [Vercel Sandbox docs](https://vercel.com/docs/vercel-sandbox) -- The underlying infrastructure 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..013f2806e --- /dev/null +++ b/examples/code-mode-http/src/tools/create-user.ts @@ -0,0 +1,35 @@ +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, + internal: true, + tags: ["users", "write"], + examples: [ + { + args: { name: "Alice", email: "alice@example.com" }, + description: "Create a new user", + }, + ], + }, +}; + +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..ae8ce22c5 --- /dev/null +++ b/examples/code-mode-http/src/tools/execute.ts @@ -0,0 +1,31 @@ +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 + .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. Pass args as a JSON string.", + annotations: { openWorldHint: true }, +}; + +export default async function execute( + { toolName, args }: { toolName: string; args: string }, + extra: ToolExtraArguments +) { + 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/get-user.ts b/examples/code-mode-http/src/tools/get-user.ts new file mode 100644 index 000000000..2ab8d8ed2 --- /dev/null +++ b/examples/code-mode-http/src/tools/get-user.ts @@ -0,0 +1,31 @@ +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, + internal: true, + tags: ["users", "read"], + examples: [{ args: { userId: "1" }, description: "Get Alice's profile" }], + }, +}; + +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..4d751de1f --- /dev/null +++ b/examples/code-mode-http/src/tools/list-users.ts @@ -0,0 +1,21 @@ +import type { ToolMetadata } from "xmcp"; + +export const metadata: ToolMetadata = { + name: "list-users", + description: "List all users in the system", + annotations: { + readOnlyHint: true, + internal: true, + tags: ["users", "read"], + }, +}; + +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..e503bdc52 --- /dev/null +++ b/examples/code-mode-http/src/tools/search.ts @@ -0,0 +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() + .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, 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, tag }: { query?: string; tag?: string }, + extra: ToolExtraArguments +) { + 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/examples/code-mode-http/xmcp.config.ts b/examples/code-mode-http/xmcp.config.ts new file mode 100644 index 000000000..65f751fdf --- /dev/null +++ b/examples/code-mode-http/xmcp.config.ts @@ -0,0 +1,15 @@ +import { XmcpConfig } from "xmcp"; + +const config: XmcpConfig = { + http: true, + paths: { + tools: "./src/tools", + prompts: false, + resources: false, + }, + typescript: { + skipTypeCheck: true, + }, +}; + +export default config; diff --git a/examples/openapi-code-mode/.env.example b/examples/openapi-code-mode/.env.example new file mode 100644 index 000000000..906dadc45 --- /dev/null +++ b/examples/openapi-code-mode/.env.example @@ -0,0 +1,2 @@ +API_BASE_URL=https://petstore3.swagger.io/api/v3 +API_KEY= diff --git a/examples/openapi-code-mode/package.json b/examples/openapi-code-mode/package.json new file mode 100644 index 000000000..e81ee3d9c --- /dev/null +++ b/examples/openapi-code-mode/package.json @@ -0,0 +1,20 @@ +{ + "name": "OpenAPI Code Mode", + "description": "Wrap any REST API as a Code Mode MCP server using sandboxed JavaScript execution", + "keywords": [ + "http", + "code-mode", + "openapi", + "sandbox" + ], + "scripts": { + "build": "xmcp build", + "dev": "xmcp dev", + "start": "node dist/http.js" + }, + "dependencies": { + "xmcp": "workspace:*", + "zod": "^4.0.10", + "@xmcp-dev/sandbox": "workspace:*" + } +} diff --git a/examples/openapi-code-mode/src/tools/custom-search.ts b/examples/openapi-code-mode/src/tools/custom-search.ts new file mode 100644 index 000000000..e9675bf42 --- /dev/null +++ b/examples/openapi-code-mode/src/tools/custom-search.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import type { ToolMetadata } from "xmcp"; +import { runInSandbox } from "@xmcp-dev/sandbox"; + +const SPEC_URL = "https://petstore3.swagger.io/api/v3/openapi.json"; +let cachedSpec: string | null = null; + +async function loadSpec(): Promise { + if (cachedSpec) return cachedSpec; + const res = await fetch(SPEC_URL); + if (!res.ok) throw new Error(`Failed to load spec: ${res.status}`); + cachedSpec = await res.text(); + return cachedSpec; +} + +export const schema = { + code: z + .string() + .describe( + "JavaScript code to search the API spec. " + + "Available global: `spec` (JSON string). " + + "Parse with JSON.parse(spec), then filter/map. " + + "Example: `const s = JSON.parse(spec); return Object.keys(s.paths)`" + ), +}; + +export const metadata: ToolMetadata = { + name: "custom-search", + description: + "Search API endpoints with JavaScript (custom implementation using runInSandbox). " + + "Same as the search tool but built manually to show the raw API.", +}; + +export default async function customSearch({ code }: { code: string }) { + // 1. Load spec (custom logic — you can load from file, DB, private API, etc.) + let specJson: string; + try { + specJson = await loadSpec(); + } catch (e: any) { + return { + content: [ + { type: "text", text: `Failed to load API spec: ${e.message}` }, + ], + isError: true, + }; + } + + // 2. Run agent code in sandbox (full control over options) + const result = await runInSandbox(code, { + globals: { spec: specJson }, + timeoutMs: 10000, + networkPolicy: "deny-all", // search is data-only, no network needed + }); + + // 3. Handle result (custom error handling, logging, transformation, etc.) + if (!result.success) { + return { + content: [{ type: "text", text: result.error! }], + isError: true, + }; + } + + return JSON.stringify(result.data, null, 2); +} diff --git a/examples/openapi-code-mode/src/tools/execute.ts b/examples/openapi-code-mode/src/tools/execute.ts new file mode 100644 index 000000000..a223b6105 --- /dev/null +++ b/examples/openapi-code-mode/src/tools/execute.ts @@ -0,0 +1,11 @@ +import { executeTool } from "@xmcp-dev/sandbox"; + +const execute = executeTool({ + url: process.env.API_BASE_URL || "https://petstore3.swagger.io/api/v3", + env: ["API_KEY"], + networkPolicy: { allow: ["petstore3.swagger.io"] }, +}); + +export const schema = execute.schema; +export const metadata = execute.metadata; +export default execute.handler; diff --git a/examples/openapi-code-mode/src/tools/search.ts b/examples/openapi-code-mode/src/tools/search.ts new file mode 100644 index 000000000..0f8cc4eaf --- /dev/null +++ b/examples/openapi-code-mode/src/tools/search.ts @@ -0,0 +1,13 @@ +import { searchTool } from "@xmcp-dev/sandbox"; + +// Factory approach: helpers enabled by default. +// Agents get search(query), filter({ method, tag, path }), endpoints array, and raw spec. +const search = searchTool({ + url: "https://petstore3.swagger.io/api/v3/openapi.json", + // helpers: true (default) — injects search(), filter(), endpoints + // helpers: false — only raw spec global +}); + +export const schema = search.schema; +export const metadata = search.metadata; +export default search.handler; diff --git a/examples/openapi-code-mode/xmcp.config.ts b/examples/openapi-code-mode/xmcp.config.ts new file mode 100644 index 000000000..65f751fdf --- /dev/null +++ b/examples/openapi-code-mode/xmcp.config.ts @@ -0,0 +1,15 @@ +import { XmcpConfig } from "xmcp"; + +const config: XmcpConfig = { + http: true, + paths: { + tools: "./src/tools", + prompts: false, + resources: false, + }, + typescript: { + skipTypeCheck: true, + }, +}; + +export default config; diff --git a/package.json b/package.json index c964aaaf2..b2eb538b9 100644 --- a/package.json +++ b/package.json @@ -83,4 +83,4 @@ "engines": { "node": "20.x" } -} +} \ No newline at end of file diff --git a/packages/plugins/sandbox/package.json b/packages/plugins/sandbox/package.json new file mode 100644 index 000000000..4a4577a2a --- /dev/null +++ b/packages/plugins/sandbox/package.json @@ -0,0 +1,47 @@ +{ + "name": "@xmcp-dev/sandbox", + "description": "Sandboxed JavaScript code execution for xmcp tools using QuickJS WebAssembly", + "version": "0.1.0", + "author": { + "name": "xmcp", + "email": "support@xmcp.dev", + "url": "https://xmcp.dev" + }, + "license": "MIT", + "keywords": [ + "xmcp", + "sandbox", + "quickjs", + "wasm", + "code-mode", + "mcp" + ], + "repository": { + "type": "git", + "url": "https://github.com/basementstudio/xmcp", + "directory": "packages/plugins/sandbox" + }, + "homepage": "https://xmcp.dev", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "dev": "tsc --watch", + "build": "rm -rf dist && tsc" + }, + "dependencies": { + "@vercel/sandbox": "^1.8.0", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "^22.19.2", + "typescript": "^5.9.3", + "xmcp": "workspace:*" + }, + "peerDependencies": { + "xmcp": "workspace:*" + } +} diff --git a/packages/plugins/sandbox/src/code-mode.ts b/packages/plugins/sandbox/src/code-mode.ts new file mode 100644 index 000000000..0100e615a --- /dev/null +++ b/packages/plugins/sandbox/src/code-mode.ts @@ -0,0 +1,198 @@ +import { z } from "zod"; +import { runInSandbox } from "./sandbox.js"; +import { SEARCH_HELPERS } from "./helpers.js"; +import type { + SearchToolConfig, + ExecuteToolConfig, + ToolModule, +} from "./types.js"; + +// Spec cache shared across all searchTool instances +const specCache = new Map(); + +async function loadSpec(url: string): Promise { + const cached = specCache.get(url); + if (cached) return cached; + + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `Failed to load spec from ${url}: ${res.status} ${res.statusText}` + ); + } + + const text = await res.text(); + specCache.set(url, text); + return text; +} + +/** + * Create a pre-configured search tool for Code Mode. + * + * Returns a complete xmcp tool module (schema, metadata, handler). + * The handler loads the OpenAPI spec, injects it as a `spec` global, + * and runs agent code in a Vercel Sandbox. + * + * @example + * ```typescript + * // src/tools/search.ts + * import { searchTool } from "@xmcp-dev/sandbox"; + * const search = searchTool({ url: "https://petstore3.swagger.io/api/v3/openapi.json" }); + * export const schema = search.schema; + * export const metadata = search.metadata; + * export default search.handler; + * ``` + */ +export function searchTool(config: SearchToolConfig): ToolModule { + const { + url, + timeoutMs = 10000, + networkPolicy = "deny-all", + helpers = true, + } = config; + + const helpersDesc = helpers + ? "Available helpers: `search(query)` for fuzzy text search, " + + "`filter({ method?, tag?, path? })` for structured filtering, " + + "`endpoints` array of all parsed endpoints. " + : ""; + + return { + schema: { + code: z + .string() + .describe( + "JavaScript code to search the API spec. " + + "Available global: `spec` (JSON string of the full OpenAPI spec). " + + helpersDesc + + "Example: `return search('pet')` or `return filter({ method: 'GET', tag: 'pet' })` " + + "or raw: `const s = JSON.parse(spec); return Object.keys(s.paths)`" + ), + }, + + metadata: { + name: "search", + description: + "Search API endpoints by writing JavaScript code. " + + (helpers + ? "Built-in helpers: search(query) for fuzzy search, filter({ method, tag, path }) for structured filtering, endpoints array for all parsed endpoints. " + : "") + + "The raw `spec` global is always available for custom queries.", + }, + + handler: async ({ code }: { code: string }) => { + let specJson: string; + try { + specJson = await loadSpec(url); + } catch (e: any) { + return { + content: [ + { type: "text", text: `Failed to load API spec: ${e.message}` }, + ], + isError: true, + }; + } + + // Prepend helper functions if enabled + const fullCode = helpers ? `${SEARCH_HELPERS}\n${code}` : code; + + const result = await runInSandbox(fullCode, { + globals: { spec: specJson }, + networkPolicy, + timeoutMs, + }); + + if (!result.success) { + return { + content: [{ type: "text", text: result.error! }], + isError: true, + }; + } + + return JSON.stringify(result.data, null, 2); + }, + }; +} + +/** + * Create a pre-configured execute tool for Code Mode. + * + * Returns a complete xmcp tool module (schema, metadata, handler). + * The handler injects `url` as a global and forwards specified + * env vars into the sandbox. Agent code uses `fetch()` directly. + * + * @example + * ```typescript + * // src/tools/execute.ts + * import { executeTool } from "@xmcp-dev/sandbox"; + * const execute = executeTool({ + * url: "https://petstore3.swagger.io/api/v3", + * networkPolicy: { allow: ["petstore3.swagger.io"] }, + * }); + * export const schema = execute.schema; + * export const metadata = execute.metadata; + * export default execute.handler; + * ``` + */ +export function executeTool(config: ExecuteToolConfig): ToolModule { + const { + url, + env: envKeys = [], + networkPolicy, + timeoutMs = 30000, + packages, + } = config; + + return { + schema: { + code: z + .string() + .describe( + "JavaScript code to execute API calls. " + + "Available global: `url` (API base URL). " + + (envKeys.length + ? `Environment variables available via process.env: ${envKeys.join(", ")}. ` + : "") + + "Use fetch() directly to make HTTP requests. " + + "Supports chaining multiple await calls. " + + `Example: \`const res = await fetch(url + '/pet/1'); return await res.json()\`` + ), + }, + + metadata: { + name: "execute", + description: + "Execute API calls by writing JavaScript code in an isolated sandbox. " + + "Use fetch() with the injected `url` global. " + + "Supports chaining multiple await calls, loops, and Promise.all. " + + "Use the search tool first to discover endpoints and their parameters.", + }, + + handler: async ({ code }: { code: string }) => { + // Forward specified env vars from host to sandbox + const env: Record = {}; + for (const key of envKeys) { + if (process.env[key]) { + env[key] = process.env[key]!; + } + } + + const result = await runInSandbox(code, { + globals: { url }, + env: Object.keys(env).length > 0 ? env : undefined, + networkPolicy, + timeoutMs, + packages, + }); + + if (!result.success) { + return { + content: [{ type: "text", text: result.error! }], + isError: true, + }; + } + + return JSON.stringify(result.data, null, 2); + }, + }; +} diff --git a/packages/plugins/sandbox/src/helpers.ts b/packages/plugins/sandbox/src/helpers.ts new file mode 100644 index 000000000..006f05f34 --- /dev/null +++ b/packages/plugins/sandbox/src/helpers.ts @@ -0,0 +1,84 @@ +/** + * JavaScript code injected into the sandbox VM alongside agent code. + * Provides search(query), filter(opts), and endpoints global. + * This string is prepended to the agent's script inside the sandbox. + */ +export const SEARCH_HELPERS = ` +// Parse the spec into a searchable endpoints array +const __spec = JSON.parse(spec); +const endpoints = []; +for (const [path, methods] of Object.entries(__spec.paths || {})) { + for (const [method, op] of Object.entries(methods)) { + if (method === 'parameters' || method === 'summary' || method === 'description') continue; + endpoints.push({ + path, + method: method.toUpperCase(), + summary: op.summary || '', + description: op.description || '', + tags: op.tags || [], + parameters: op.parameters || [], + operationId: op.operationId || '', + }); + } +} + +// Fuzzy text search: matches query against path, summary, description, tags, operationId +function search(query) { + if (!query) return endpoints; + const q = query.toLowerCase(); + const terms = q.split(/\\s+/); + + return endpoints + .map(ep => { + const text = [ep.path, ep.method, ep.summary, ep.description, ep.operationId, ...ep.tags] + .join(' ') + .toLowerCase(); + + // Score: count how many terms match + fuzzy bonus for partial matches + let score = 0; + for (const term of terms) { + if (text.includes(term)) { + score += 1; + } else { + // Fuzzy: check if any word in text is close to the term (Levenshtein-like) + const words = text.split(/[\\s\\/\\-_]+/); + for (const word of words) { + if (word.length > 1 && term.length > 1) { + const maxLen = Math.max(word.length, term.length); + let dist = 0; + for (let i = 0; i < maxLen; i++) { + if (word[i] !== term[i]) dist++; + } + if (dist / maxLen < 0.4) { + score += 0.5; + break; + } + } + } + } + } + return { ...ep, _score: score }; + }) + .filter(ep => ep._score > 0) + .sort((a, b) => b._score - a._score) + .map(({ _score, ...ep }) => ep); +} + +// Structured filter: match by method, tag, or path pattern +function filter(opts = {}) { + let results = endpoints; + if (opts.method) { + const m = opts.method.toUpperCase(); + results = results.filter(ep => ep.method === m); + } + if (opts.tag) { + const t = opts.tag.toLowerCase(); + results = results.filter(ep => ep.tags.some(tag => tag.toLowerCase() === t)); + } + if (opts.path) { + const p = opts.path.toLowerCase(); + results = results.filter(ep => ep.path.toLowerCase().includes(p)); + } + return results; +} +`; diff --git a/packages/plugins/sandbox/src/index.ts b/packages/plugins/sandbox/src/index.ts new file mode 100644 index 000000000..d2deefa46 --- /dev/null +++ b/packages/plugins/sandbox/src/index.ts @@ -0,0 +1,11 @@ +export { runInSandbox, createSnapshot } from "./sandbox.js"; +export { searchTool, executeTool } from "./code-mode.js"; +export type { + SandboxOptions, + SandboxResult, + CreateSnapshotOptions, + SearchToolConfig, + ExecuteToolConfig, + ToolModule, + NetworkPolicy, +} from "./types.js"; diff --git a/packages/plugins/sandbox/src/sandbox.ts b/packages/plugins/sandbox/src/sandbox.ts new file mode 100644 index 000000000..46a8222f1 --- /dev/null +++ b/packages/plugins/sandbox/src/sandbox.ts @@ -0,0 +1,185 @@ +import { Sandbox } from "@vercel/sandbox"; +import type { + SandboxOptions, + SandboxResult, + CreateSnapshotOptions, +} from "./types.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Execute JavaScript code in an isolated Vercel Sandbox (Firecracker microVM). + * + * - Globals are injected as const variables (string values) + * - Env vars are injected into process.env (use for secrets) + * - Agent code has access to fetch() for HTTP calls + * - Full async/await support (chained awaits, loops, Promise.all) + * - OS-level isolation (separate filesystem, network, process space) + * - Always returns SandboxResult, never throws + */ +export async function runInSandbox( + code: string, + options?: SandboxOptions +): Promise { + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + let sandbox: Awaited> | null = null; + + try { + // Create VM: from snapshot or fresh + const baseParams = { + timeout: timeoutMs + 5000, + env: options?.env, + networkPolicy: options?.networkPolicy, + }; + + if (options?.snapshotId) { + sandbox = await Sandbox.create({ + ...baseParams, + source: { type: "snapshot" as const, snapshotId: options.snapshotId }, + }); + } else { + sandbox = await Sandbox.create({ + ...baseParams, + runtime: (options?.runtime ?? "node24") as any, + }); + } + + // Install packages if requested + if (options?.packages?.length) { + const installCmd = await sandbox.runCommand("npm", [ + "install", + "--no-audit", + "--no-fund", + ...options.packages, + ]); + if (installCmd.exitCode !== 0) { + const stderr = await installCmd.stderr(); + return { + success: false, + error: `Failed to install packages: ${stderr}`, + }; + } + } + + // Build script: declare globals as const + wrap agent code in async IIFE + const globalsCode = Object.entries(options?.globals ?? {}) + .map(([name, value]) => `const ${name} = ${JSON.stringify(value)};`) + .join("\n"); + + const script = ` +${globalsCode} +(async () => { + try { + const __result = await (async () => { ${code} })(); + process.stdout.write(JSON.stringify({ ok: true, data: __result })); + } catch (e) { + process.stdout.write(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) })); + } +})(); +`; + + await sandbox.writeFiles([ + { path: "/tmp/run.js", content: Buffer.from(script) }, + ]); + + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + + let cmd; + try { + cmd = await sandbox.runCommand("node", ["/tmp/run.js"], { + signal: abortController.signal, + }); + } finally { + clearTimeout(timer); + } + + if (cmd.exitCode !== 0) { + const stderr = await cmd.stderr(); + return { + success: false, + error: stderr || `Process exited with code ${cmd.exitCode}`, + }; + } + + const stdout = await cmd.stdout(); + if (!stdout.trim()) { + return { success: true, data: undefined }; + } + + const parsed = JSON.parse(stdout); + return parsed.ok + ? { success: true, data: parsed.data } + : { success: false, error: parsed.error }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + if ( + message.includes("unauthorized") || + message.includes("OIDC") || + message.includes("credentials") || + message.includes("401") + ) { + return { + success: false, + error: + "Vercel Sandbox requires authentication. Run 'vercel link' and 'vercel env pull' for local development.", + }; + } + + if (message.includes("abort") || message.includes("timeout")) { + return { + success: false, + error: `Execution timed out after ${timeoutMs}ms`, + }; + } + + return { success: false, error: message }; + } finally { + if (sandbox) { + try { + await sandbox.stop(); + } catch { + // Ignore cleanup errors + } + } + } +} + +/** + * Create a reusable snapshot with pre-installed packages. + * Use the returned snapshotId in runInSandbox({ snapshotId }) for faster execution. + * + * Snapshots expire after 30 days by default on Vercel. + */ +export async function createSnapshot( + options?: CreateSnapshotOptions +): Promise { + const sandbox = await Sandbox.create({ runtime: "node24" }); + + try { + if (options?.packages?.length) { + const installCmd = await sandbox.runCommand("npm", [ + "install", + "--no-audit", + "--no-fund", + ...options.packages, + ]); + if (installCmd.exitCode !== 0) { + const stderr = await installCmd.stderr(); + throw new Error(`Failed to install packages: ${stderr}`); + } + } + + const snapshot = await sandbox.snapshot(); + return snapshot.snapshotId; + } catch (err) { + // Ensure sandbox is stopped even if snapshot fails + try { + await sandbox.stop(); + } catch { + // Ignore + } + throw err; + } +} diff --git a/packages/plugins/sandbox/src/types.ts b/packages/plugins/sandbox/src/types.ts new file mode 100644 index 000000000..3c1cd0cec --- /dev/null +++ b/packages/plugins/sandbox/src/types.ts @@ -0,0 +1,90 @@ +import type { NetworkPolicy } from "@vercel/sandbox"; + +/** Configuration for a single sandbox execution */ +export interface SandboxOptions { + /** Named values available to agent code inside the sandbox. Each value is a string (raw or JSON-serialized). */ + globals?: Record; + /** Maximum execution time in milliseconds (default: 30000) */ + timeoutMs?: number; + /** Environment variables injected into the VM's process.env. Use for secrets instead of globals. */ + env?: Record; + /** + * Network policy for the sandbox VM. + * - "allow-all" (default): full internet access + * - "deny-all": no network access + * - { allow: ["domain.com"] }: allowlist specific domains + */ + networkPolicy?: NetworkPolicy; + /** npm packages to install in the VM before executing agent code. */ + packages?: string[]; + /** Resume from a snapshot instead of a fresh VM. Use createSnapshot() to create one. */ + snapshotId?: string; + /** VM runtime. Supports "node24" (default), "node22", "python3.13". */ + runtime?: string; +} + +/** Options for creating a snapshot */ +export interface CreateSnapshotOptions { + /** npm packages to pre-install in the snapshot */ + packages?: string[]; + /** Environment variables to set in the snapshot */ + env?: Record; +} + +/** The outcome of a sandbox execution. Always returned, never throws. */ +export interface SandboxResult { + /** Whether execution completed without error */ + success: boolean; + /** The return value from agent code (if success) */ + data?: unknown; + /** Error message (if failure) */ + error?: string; +} + +/** Configuration for the searchTool factory */ +export interface SearchToolConfig { + /** URL of the OpenAPI spec to load and inject as the `spec` global */ + url: string; + /** Maximum execution time in milliseconds (default: 10000) */ + timeoutMs?: number; + /** Network policy for the sandbox VM (default: "deny-all" since search is data-only) */ + networkPolicy?: NetworkPolicy; + /** + * Inject built-in search helper functions into the sandbox. + * When true, agent code gets: + * - `search(query)` — fuzzy text search across endpoint paths, summaries, and descriptions + * - `filter({ method?, tag?, path? })` — structured filtering by HTTP method, tag, or path pattern + * - `endpoints` — pre-parsed array of all endpoints with { path, method, summary, description, tags, parameters } + * + * The raw `spec` global is always available regardless of this option. + * Default: true + */ + helpers?: boolean; +} + +/** Configuration for the executeTool factory */ +export interface ExecuteToolConfig { + /** API base URL injected as the `url` global */ + url: string; + /** Host env var names to forward into the sandbox's process.env (e.g. ["API_KEY"]) */ + env?: string[]; + /** Network policy for the sandbox VM (default: "allow-all") */ + networkPolicy?: NetworkPolicy; + /** Maximum execution time in milliseconds (default: 30000) */ + timeoutMs?: number; + /** npm packages to install in the VM before execution */ + packages?: string[]; +} + +/** A complete xmcp tool module returned by factory functions */ +export interface ToolModule { + schema: Record; + metadata: { + name: string; + description: string; + annotations?: Record; + }; + handler: (args: { code: string }) => Promise; +} + +export type { NetworkPolicy }; diff --git a/packages/plugins/sandbox/tsconfig.json b/packages/plugins/sandbox/tsconfig.json new file mode 100644 index 000000000..ccb45073b --- /dev/null +++ b/packages/plugins/sandbox/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} 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/tool-registry.ts b/packages/xmcp/src/runtime/utils/tool-registry.ts new file mode 100644 index 000000000..fa38c7875 --- /dev/null +++ b/packages/xmcp/src/runtime/utils/tool-registry.ts @@ -0,0 +1,170 @@ +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; + mcpHandler: ReturnType; + 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, + mcpHandler: transformToolHandler(handler, undefined, outputSchema, name), + 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 { + const result = await entry.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/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index ec69c7922..c0571e8ba 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,12 +171,38 @@ export function addToolsToServer( _meta: flattenedToolMeta, // Use flattened metadata for MCP protocol }; - // server as any prevents infinite type recursion - (server as any).registerTool( + // Register in the tool registry for listTools/callTool support + registry.register( toolConfig.name, - toolConfigFormatted, - transformedHandler + 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 extraTools: any = { + ...extra, + listTools: () => registry.list(), + callTool: (name: string, toolArgs: Record) => + registry.call(name, toolArgs, extraTools), + }; + return originalHandler(args, extraTools); + }; + + // 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 8dd8c2588..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; } @@ -99,6 +101,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> = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31403d190..ff38f2cb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: @@ -652,6 +661,18 @@ importers: specifier: ^4.0.10 version: 4.1.13 + examples/openapi-code-mode: + dependencies: + '@xmcp-dev/sandbox': + specifier: workspace:* + version: link:../../packages/plugins/sandbox + xmcp: + specifier: workspace:* + version: link:../../packages/xmcp + zod: + specifier: ^4.0.10 + version: 4.1.13 + examples/polar-http: dependencies: '@xmcp-dev/polar': @@ -1146,6 +1167,25 @@ importers: specifier: workspace:* version: link:../../xmcp + packages/plugins/sandbox: + dependencies: + '@vercel/sandbox': + specifier: ^1.8.0 + version: 1.9.0 + zod: + specifier: 'catalog:' + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.19.2 + version: 22.19.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + xmcp: + specifier: workspace:* + version: link:../../xmcp + packages/plugins/workos: dependencies: '@workos-inc/node': @@ -4506,6 +4546,13 @@ packages: resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + + '@vercel/sandbox@1.9.0': + resolution: {integrity: sha512-zgr1ad0tkT1xZn/8Vxo60wOUOLqMAVGo4WqJQ8/UDcUtWynNJsBjI2tiMdWZrAo9EKH1MIqEzJNkcclF0UT1EQ==} + '@vercel/speed-insights@1.3.1': resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==} peerDependencies: @@ -4829,6 +4876,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4839,6 +4894,14 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -5762,6 +5825,9 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -5809,6 +5875,9 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -6699,6 +6768,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -7433,6 +7505,10 @@ packages: resolution: {integrity: sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==} engines: {node: '>=16'} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -8278,6 +8354,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -8434,6 +8513,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.11: resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} engines: {node: '>=18'} @@ -8459,6 +8541,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + text-segmentation@1.0.3: resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} @@ -8968,6 +9053,14 @@ packages: utf-8-validate: optional: true + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xmcp@0.5.7: resolution: {integrity: sha512-NnBx+1P7v6Apah5lgtxZxojhp/lP1qLjTTS/0AVear60akULVYL5f54n9w7XjHvxm59JqNBppOVYdm8zemehYw==} hasBin: true @@ -9064,6 +9157,9 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -12153,6 +12249,23 @@ snapshots: '@vercel/oidc@3.0.5': {} + '@vercel/oidc@3.2.0': {} + + '@vercel/sandbox@1.9.0': + dependencies: + '@vercel/oidc': 3.2.0 + async-retry: 1.3.3 + jsonlines: 0.1.1 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.24.0 + xdg-app-paths: 5.1.0 + zod: 3.24.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + '@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(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -12513,12 +12626,16 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.8.0: {} + bail@2.0.2: {} balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bare-events@2.8.2: {} + base64-arraybuffer@1.0.2: {} base64-js@1.5.1: {} @@ -13661,6 +13778,12 @@ snapshots: eventemitter3@5.0.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} eventsource-parser@3.0.6: {} @@ -13760,6 +13883,8 @@ snapshots: fast-diff@1.3.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14745,6 +14870,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonlines@0.1.1: {} + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -15798,6 +15925,8 @@ snapshots: string-width: 6.1.0 strip-ansi: 7.1.2 + os-paths@4.4.0: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -16842,6 +16971,15 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -17053,6 +17191,15 @@ snapshots: tapable@2.3.0: {} + tar-stream@3.1.7: + dependencies: + b4a: 1.8.0 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@7.5.11: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -17089,6 +17236,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + text-segmentation@1.0.3: dependencies: utrie: 1.0.2 @@ -17719,6 +17872,14 @@ snapshots: ws@8.18.0: {} + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xmcp@0.5.7(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1)(zod@4.1.13): dependencies: '@modelcontextprotocol/sdk': 1.26.0(zod@4.1.13) @@ -17844,6 +18005,8 @@ snapshots: dependencies: zod: 4.1.13 + zod@3.24.4: {} + zod@3.25.76: {} zod@4.1.13: {}