mcp

import { mcp } from '@routecraft/ai'

Expose capabilities as MCP tools or call remote MCP servers. Requires mcpPlugin() in your context plugins when used as a source.

Source mode -- define a discoverable MCP tool:

The tool name is the route id; the tool's title, description, and schemas live on the route builder (enforced framework-wide). Only MCP-protocol extras (annotations, icons, ui) remain on mcp() itself.

import { mcp } from '@routecraft/ai'
import { z } from 'zod'

craft()
  .id('fetch-webpage')
  .title('Fetch webpage')
  .description('Fetch the content of a webpage')
  .input({ body: z.object({ url: z.string().url() }) })
  .output({ body: z.object({ content: z.string() }) })
  .from(mcp({ annotations: { readOnlyHint: true, openWorldHint: true } }))
  .transform(async ({ url }) => {
    const res = await fetch(url)
    return { content: await res.text() }
  })

A non-empty .description() is required for every MCP source route (surfaced as the tool description in tools/list); the route fails to subscribe otherwise. The tool name (route id) is validated against the MCP interop regex ^[A-Za-z0-9_-]{1,64}$.

Client mode -- call a remote MCP tool:

The client is an enricher: in .to() and bare .enrich() the tool result replaces the body (pass an aggregator such as only() to merge instead); .tap() discards it.

// Recommended: by server id registered in mcpPlugin({ clients }).
// Auth is inherited from the client config automatically.
.enrich(mcp('browser:browser_navigate', { args: (ex) => ({ url: ex.body.url }) }))

// By URL and tool name (use inline auth if needed)
.enrich(mcp({ url: 'http://127.0.0.1:8089/mcp', tool: 'browser_navigate' }, { args: (ex) => ({ url: ex.body.url }) }))

When using the serverId path (recommended), auth configured on the client in mcpPlugin({ clients }) flows to the tool call automatically. Inline auth on McpClientOptions is available as an escape hatch for the raw url path or to override registered config, but prefer centralizing credentials in the plugin config.

Options (McpServerOptions -- source, protocol extras only):

OptionTypeRequiredDescription
annotationsMcpToolAnnotationsNoBehavior hints forwarded to MCP clients in the tools/list response
iconsMcpIcon[]NoIcons forwarded on tools/list per the MCP spec
uiMcpUiOptionsNoAn MCP Apps view the host renders the tool result in. Requires .output()

All other tool metadata (title, description, input / output schemas) comes from the route builder and is enforced framework-wide:

Builder methodMaps toNotes
.id('tool-name')tool.nameValidated against ^[A-Za-z0-9_-]{1,64}$ at subscribe
.title('...')tool.titleOptional display title
.description('...')tool.descriptionRequired for MCP source routes
.input({ body, headers })tool.inputSchema + runtime checkbody validation is framework-enforced; headers validated values merge over the originals
.output({ body, headers })tool.outputSchema + runtime checkAdvertised to clients and enforced on the way out (see below)

Output enforcement:

A route that declares .output({ body }) advertises that schema as the tool's outputSchema, and a client is entitled to parse the result's structuredContent against it. The server therefore checks the body it is about to publish and refuses to return one the schema rejects:

craft()
  .id('list-users')
  .description('List users')
  .output({ body: z.object({ users: z.array(z.object({ id: z.string() })) }) })
  .from(mcp())
  .transform(loadUsers);

If loadUsers returns something else, the call comes back as isError: true naming the failing fields rather than as a result contradicting the advertised schema.

Two checks stand behind that promise. The route's own output validation runs first and covers every result it completes, reporting a violation as RC5002. The server then checks anything that reached it without passing through that validation, and reports those as AI2001. A body the route already validated is not checked twice: validation replaces the body with the schema's output, so a schema that transforms (z.string().transform(...), .pipe()) would reject the value it just produced.

A tool whose route declares no .output() advertises no schema, so nothing is checked and its result passes through as-is.

Non-object outputs. .output({ body }) accepts any Standard Schema, including one whose JSON Schema root is not an object (z.string(), z.array(...)). MCP's 2025 protocol revision requires structuredContent to be an object, so on that revision the advertised schema and the published result both arrive inside the SEP-2106 envelope: outputSchema becomes { type: 'object', properties: { result: <yours> }, required: ['result'] } and the result becomes { "result": "42.50" }. The 2026-07-28 revision carries both bare. This is a wire concern only. Your route declares and returns the bare value, the text content block still renders it unwrapped, and nothing in the pipeline ever sees { result: ... }.

One exception to "advertises that schema": a route that can defer at a durable deferral advertises oneOf: [Output, Deferred] instead, because a call that defers returns the framework's Deferred acknowledgment rather than the declared output. See Deferrable tools.

Declined calls:

A route that drops the exchange (a .filter() rejecting, a .choice() matching no branch, an error handler returning recovery.drop()) has produced no result. The call comes back as isError: true saying the tool declined the request (AI2002), which mirrors RC5031 on the direct and forward surfaces. This applies to every tool, declared output or not.

Failed calls:

A call whose route fails comes back as isError: true with a single text block. The error code crosses the wire and the error message does not: a route failure is whatever its steps threw, and error messages routinely carry hostnames, file paths, route ids and upstream response text. The full message goes to the log and to the plugin:mcp:tool:failed event, where the operator reads it.

A failure the caller caused on the tool's own route keeps a reason an agent can act on:

FailureText the caller receives
The tool's .input() schema refuses the arguments (RC5065)Tool "search" rejected its arguments (RC5065): "query": Too small: ... with each issue's path and message, at most 20, the rest counted as and N more
The tool's .authorize() refuses a role, a predicate or a delegation (RC5015, RC5034, RC5035, RC5036)Tool "archive" refused the call: insufficient permissions.
The tool's .authorize() finds a scope missing (RC5038)Tool "archive" refused the call: insufficient scope, missing: orders:write. (or requires one of: for anyScope)
The caller's credential expired before .authorize() ran (RC5020)Tool "archive" refused the call: the credential expired. Refresh it and retry.
.authorize() finds no principal on an HTTP mount with a validator (RC5012)Tool "archive" requires an authenticated caller. Connect with a credential and retry.
The result body breaks the declared output schema (RC5002, AI2001); a headers violation answers the generic text, since the headers schema is not advertisedMCP tool "list-users" returned a body that does not match its declared output schema (RC5002): "users.0.id": ...
Anything elseTool "search" failed (RC5001).

Every line is prefixed with Error: . Attribution is by where the failure came from, never by code alone. An input refusal or an authorization refusal raised by a route the tool calls through direct() is the tool's own fault, not the caller's, and answers with the generic line. So does an authorize() check of an identity the pipeline swapped in (.authenticate(), a delegation), and the same codes thrown by an adapter for an upstream login refused. A missing principal is the caller's to fix only where a credential could have supplied one: on stdio, or on an HTTP mount with no validator, it is the generic line. The four permission codes share one text so a caller cannot tell which check it tripped.

McpToolAnnotations (optional hint fields, all booleans unless noted):

These mirror the MCP specification (2025-03-26) ToolAnnotations shape. They are hints only; clients must not rely on them for correctness or safety.

FieldTypeDescription
titlestringHuman-readable title for the tool (used for display in UIs).
readOnlyHintbooleanWhen true, the tool does not modify any state. Clients assume false when omitted.
destructiveHintbooleanWhen true, the tool may perform destructive operations. Clients assume true when omitted.
idempotentHintbooleanWhen true, calling the tool repeatedly with the same arguments has no additional effect. Clients assume false when omitted.
openWorldHintbooleanWhen true, the tool may interact with external systems (network, filesystem, etc.). Clients assume true when omitted.

Derived from route tags: the four behavior hints are also derived from the route's .tag() values, so you declare the fact once instead of as both a tag and an annotation. read-only sets readOnlyHint, destructive sets destructiveHint, idempotent sets idempotentHint, and open-world sets openWorldHint. Explicit annotations passed to mcp() override the derived values per-key.

// These two routes expose the same annotations to MCP clients:
.tag('read-only').tag('open-world').from(mcp())
.from(mcp({ annotations: { readOnlyHint: true, openWorldHint: true } }))

MCP Apps views

ui attaches an MCP Apps view to the tool: HTML the host renders the result in instead of showing JSON. The tool advertises _meta.ui.resourceUri on tools/list, and the server serves the view at that ui:// URI through resources/read with MIME type text/html;profile=mcp-app. Hosts without MCP Apps support read the text result as before. See Rendering results as a view for a working view.

import { fileURLToPath } from 'node:url'
import { craft } from '@routecraft/routecraft'
import { fromFile, mcp } from '@routecraft/ai'
import { z } from 'zod'

const mailCard = fileURLToPath(new URL('./mail-card.html', import.meta.url))

export default craft()
  .id('send-mail')
  .description('Hold an email for review')
  .output({ body: z.object({ to: z.string(), subject: z.string() }) })
  .from(mcp({ ui: { html: fromFile(mailCard), prefersBorder: true } }))
  .transform(() => ({ to: 'a@example.com', subject: 'Hi' }))

McpUiOptions:

FieldTypeRequiredDescription
htmlstring | (() => string | Promise<string>)YesThe view's HTML document, or a loader called on every resources/read. fromFile(path) returns a loader; its relative path resolves against the process working directory, which a stdio host chooses, so prefer an absolute path
cspMcpUiCspNoOutside origins the view may reach: connectDomains, resourceDomains, frameDomains, baseUriDomains, each a string[]. Omitted lists mean inline and own-origin only
prefersBorderbooleanNoAsk the host to draw a border around the view

Rules the adapter enforces:

  • The route must declare .output({ body }); the view draws from structuredContent. A ui without it fails when the route subscribes with RC5003 naming the route. A malformed ui throws RC5003 when mcp() is called.
  • The URI is ui://<server name>/<route id>, derived from mcpPlugin({ name }) and the route id, so it cannot collide or drift.
  • The view is served behind the same auth as the tools and follows the tools filter: a hidden tool's view is not listed and does not resolve. Every client of the server can read it, so keep the HTML static and let data arrive through the tool result.
  • A loader that throws answers a generic error naming the tool, logs the cause, and emits plugin:mcp:ui:failed. The cause (often a host path) never reaches the caller.
  • The view receives the wire result, not your route's body: a non-object .output() arrives as { result } on the 2025 protocol revision, a deferrable tool can answer with the { status: "deferred", ... } acknowledgment, and an isError result carries no structuredContent. Write the view to handle each it can meet.
  • The view renders after the tool has run. It is not a confirmation step; use destructiveHint and the host's own permission prompt for that.

Options (McpClientOptions -- destination):

OptionTypeRequiredDescription
urlstringOne of url/serverIdDirect HTTP URL of the remote MCP server
serverIdstringOne of url/serverIdNamed server registered via mcpPlugin({ clients })
toolstringNoTool name to invoke (or set exchange.body.tool)
args(exchange) => Record<string, unknown>NoExtractor for tool arguments; defaults to exchange.body
authMcpClientAuthOptionsNoAuth credentials for HTTP requests. Auto-inherited from mcpPlugin({ clients }) when using serverId; use to override or for inline url connections

McpClientAuthOptions:

FieldTypeDescription
tokenstring | string[] | (() => string | Promise<string>)Bearer token, array of tokens (round-robin), or provider function called per request
headersRecord<string, string>Additional request headers; overrides token if Authorization is set

Tool Registry

Each .from(mcp(...)) route registers in MCP_LOCAL_TOOL_REGISTRY so the MCP server can list and invoke it via the MCP protocol:

import { MCP_LOCAL_TOOL_REGISTRY } from '@routecraft/ai'

const ctx = await new ContextBuilder().routes(...).build()
await ctx.start()

const registry = ctx.getStore(MCP_LOCAL_TOOL_REGISTRY)
const tools = registry ? Array.from(registry.values()) : []
// [{ endpoint, title?, description, input?, output?, annotations?, icons?, ui?, handler }]

mcp() and direct() maintain separate, fully isolated registries. An MCP route with .id('foo').from(mcp()) and a direct route with .id('bar').from(direct()) both register by their own ids in their own stores; direct routes never appear in the MCP tools/list response.

See Running an MCP server, Calling an MCP, and the MCP example.