mcp

import { mcp } from '@routecraft/ai'

Expose capabilities as MCP tools or call remote MCP servers. Requires the mcp config key (or mcpPlugin()) 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 }) }))

The result the client hands the route is the remote's structuredContent when it published one, otherwise its content: one text block as its string, several blocks as the array, none as the raw result. A result the remote flagged isError throws AI2003 with the remote's text, bounded, so a route fails at its boundary and can .error() or .retry() around it, and an agent sees a failed call rather than content.

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()
proxystringNoMake this route the tools of a registered client ('github:*' or 'github') or one of them ('github:create_issue'). See Proxying a remote server
prefixstringNoWhat an exposed proxied tool's name starts with; defaults to the client's name and an underscore (github_). '' exposes the remote names bare. Only with proxy
namestringNoThe exposed name of the one tool an exact proxy names, in place of the prefixed remote name. Refused with a star proxy
excludestring[]NoRemote tools a star proxy leaves out, by their names on the remote (['create_issue']): a non-empty list of valid tool names; a name the client does not list warns once and leaves nothing out. Only with a star proxy

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 below.

Deferrable tools:

A tool whose route can defer at a durable .defer(), or whose pipeline dispatches an agent (its tool handlers may defer at runtime), answers a deferred run with the framework's Deferred acknowledgment instead of its declared output. MCP has no out-of-band status channel the way HTTP has 202, so the union is real and is published:

  • Advertisement. When the route declares .output(), tools/list advertises outputSchema as the derived union oneOf: [Output, Deferred]. You declare only the output arm; the acknowledgment arm is the framework's. A tool with no .output() advertises nothing, deferrable or not.
  • The wire result. A deferral is an ordinary isError: false result whose structuredContent is the acknowledgment: { status: "deferred", deferralId, token, schema?, expiresAt? }, with the same JSON mirrored in the text block. That is the whole shape: anything the defer site attached as meta stays on the record, because this value crosses to the caller. On the 2025 protocol revision both the advertisement and the acknowledgment arrive inside the SEP-2106 { result: ... } envelope, because a oneOf root is not an object root and that revision requires one; the 2026-07-28 revision carries both bare. The envelope is applied to the wire, never to the body your route carries. The caller (a human client, or an agent that can escalate to its own caller) holds the resume token; the payload arrives through any .resume() ingress, and the route's real output flows to its destinations on execution two.
  • Events. A deferral emits plugin:mcp:tool:deferred with the deferral id, never completed (a deferred run reported as finished is a false receipt) and never failed or declined.

The advertised union over-approximates for agent-bearing routes: the framework cannot rule deferral out statically, so a client of such a tool must handle the acknowledgment even if a given deployment never defers. Note that oneOf is exact-match: a declared output schema loose enough to also accept the acknowledgment shape (an open passthrough object) makes a strict client's validation of a deferral ambiguous, so keep .output() closed.

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 } }))

Proxying a remote server

A route can be the tools of a client registered under mcpPlugin({ clients }), or one of them, so that every tool of a vendor's server is governed by one route instead of a hand-written route per tool. The source says which tools, the hop forwards the call, and everything between and after is the ordinary DSL.

// Every tool of the github client, exposed as github_<tool>.
craft()
  .id('github')                                   // the route's name in ops and logs, never a tool
  .authorize({ scopes: ['github:read'] })
  .cache({ ttl: '2m', when: readOnlyTool })       // serve the read-only tools from the cache
  .from(mcp({ proxy: 'github:*' }))               // this route IS github's tools
  .process(refuseSecrets)                         // before: refuse (throw) or rewrite the arguments
  .to(mcp('github:*'))                            // forward whichever tool was called
  .transform(stripFields(['author_email']))       // after: rewrite or refuse the result

// One tool, its own route. Wins over the star route for that tool.
craft()
  .id('github-create-issue')
  .tag('approval')
  .authorize({ roles: ['maintainer'] })
  .from(mcp({ proxy: 'github:create_issue' }))
  .transform(redact)
  .to(mcp('github:create_issue'))
  .transform(restore)

Three sentences hold the model:

  1. mcp({ proxy: 'github:*' }) as the source means this route is every tool of the client github, exposed under the remote's own names with the prefix github_. mcp({ proxy: 'github:create_issue' }) means this route is that one tool.
  2. .to(mcp('github:*')) forwards the call to the remote; with a star it forwards whichever tool was called, with the client's registered credentials. Every step above it sees the arguments as the body, every step below it sees the result.
  3. The server advertises what the client currently lists. A call for a tool the client no longer has fails downstream and comes back as a failed call. Nothing in the route refreshes anything; the client's list is what it is.

What a proxied exchange carries. The arguments are the body. The headers say where the call goes:

HeaderValue
McpHeadersKeys.TOOL (routecraft.mcp.tool)The remote tool's name, which the star hop forwards to
McpHeadersKeys.CLIENT (routecraft.mcp.client)The registered client
McpHeadersKeys.ANNOTATIONS (routecraft.mcp.annotations)The remote tool's annotations as the client listed them, for a .cache({ when }) that serves only readOnlyHint tools, or any step that treats a destructive tool differently
HeadersKeys.CACHE_DISCRIMINATORThe remote tool's name, folded into the default cache key so two tools called with equal arguments never share an entry. A custom key on a star route is used verbatim, so it has to carry the tool name itself, beside the principal and the route
const readOnlyTool = (ex: Exchange) =>
  (ex.headers[McpHeadersKeys.ANNOTATIONS] as McpToolAnnotations | undefined)?.readOnlyHint === true

Names. A proxied tool is exposed as <client>_<tool> (github_create_issue; a Claude client sees mcp__<server>__github_create_issue). The prefix is what keeps the tool list stable: a remote adding a tool collides with another server's only where the joined names coincide (foo with bar_baz and foo_bar with baz both read foo_bar_baz), which distinct prefixes rule out. prefix: '' on a star route exposes the remote names bare and accepts the collisions that come with it; name on an exact route renames the one tool. Two routes that would expose the same name keep the first (in registration order) and warn; a local .from(mcp()) route keeps a name over a proxied tool and warns. An exposed name must still match [A-Za-z0-9_-]{1,64}, so a remote tool whose prefixed name does not conform is skipped with a warning until a prefix or name makes it conform.

Mounts. Over HTTP the proxied tools are also served under the client's own URL, /mcp/<client>, by their remote names with no prefix, under the same tools filter and exclude as the root: the mount already names the client, so there is nothing for a prefix to keep apart. An exact route's tool is there under the remote name too, and under its exposed name at /mcp/<server name>, the mount for the tools the application named itself, beside the local .from(mcp()) routes. The root, /mcp, is unchanged. The mcpPlugin reference has the table and the rules.

Rules.

  • An exact route wins over a star route for the tool it names while it is live, and the star route serves the tool again when the exact route goes, whether removed or held back by .enabled(). exclude on the star route leaves named tools out of the group for good, so an exact route over one of them stands alone and nothing serves the tool when it goes. Two exact routes for one tool, or two star routes for one client, are refused at start (RC5003).
  • The route id names the route, never a tool. .description() and .title() replace the remote's for every tool the route exposes; leave them off to pass the remote's through. .tag() and annotations merge over the remote's annotations per key.
  • The remote's JSON Schemas pass through to tools/list and to an agent's catalogue; the remote validates the arguments at its door. A remote tool with an outputSchema publishes its result as structuredContent, and the server holds what the route finally returns to that advertised schema (AI2001), so a step below the hop that rewrites the result has to keep its shape. A .to(mcp('github:*')) hop forwards to the client the route proxies and refuses any other exchange (RC5003): a local .from(mcp()) route's own call, or a route proxying a different client.
  • A route that can park (a .defer(), or an approval hook on a tag) advertises the Deferred arm on every tool it exposes, and an agent holding one of those tools holds it as a background tool, as for any parking route.
  • The caller's principal selects policy on the route (.authorize(), your steps) and is never forwarded. The hop authenticates with the client's registered auth. A remote result flagged isError fails the hop with AI2003, and the caller receives the remote's text: they addressed the remote, so its answer is theirs. The same code from any other hop, or from a nested route, answers with the code alone.
  • ui is refused on a proxy source: a view belongs to a tool a route defines.

Agents. An agent's MCP(github:create_issue) reference resolves to the route that proxies it when one exists, under the reference's own name (mcp__github__create_issue), so your own agent is under the same policy as an external client: the call carries the agent's principal, so the route's .authorize() judges it the way it judges a Direct(<id>) call, and a route that declines fails the call (AI2002) instead of echoing the arguments. Reaching the client raw needs an explicit { name: 'MCP(github:create_issue)', raw: true }, which the resolver warns about once. A tool no route covers stays raw. A tool whose proxy route exists but is not live (held back by .enabled()) is withheld from the agent, with a warning, unless a live star route over the client covers it, which then governs it: the policy written for it is off, and off is closed, not open. A tool the star route's exclude names is withheld the same way, and is reached only through its own exact route or with raw: true.

Migrating a hand-written wrapping route. Before this source existed, policy on a remote tool meant a direct() route per tool that called .to(mcp('github:create_issue')), and either an agent holding Direct(<id>) or an mcpPlugin({ proxy }) entry re-exposing the tool. The same policy now sits on one route per server, or per tool where a tool needs its own:

// Before: one route per tool, reachable as Direct(github-create-issue)
craft()
  .id('github-create-issue')
  .description('Create a GitHub issue')
  .input({ body: CreateIssue })
  .authorize({ roles: ['maintainer'] })
  .from(direct())
  .to(mcp('github:create_issue'))

// After: the same tool, same policy, exposed by the MCP server and resolved by MCP(github:create_issue)
craft()
  .id('github-create-issue')
  .authorize({ roles: ['maintainer'] })
  .from(mcp({ proxy: 'github:create_issue' }))
  .to(mcp('github:create_issue'))

What changes in the move: the input schema comes from the remote, so .input() goes unless you want a narrower one; the route is exposed as github_create_issue rather than by its id; an agent references it as MCP(github:create_issue) rather than Direct(github-create-issue); a set of tools that shared a policy collapses into one star route; and an mcpPlugin({ proxy }) entry, with or without a guard, becomes a route (a guard that checked the caller becomes .authorize() or a .process() that throws). The hand-written route with its own .input() and .enrich(mcp('server:tool')) remains the escape hatch for a tool whose name, schema or shape you want to change, or that composes two upstream calls.

MCP Apps views

By default a host shows a tool's result as JSON. A host that supports the MCP Apps extension (Claude web and desktop, ChatGPT, VS Code with GitHub Copilot) can render it in an HTML view instead. ui attaches one: 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.

// capabilities/send-mail.ts
import { fileURLToPath } from 'node:url'
import { craft } from '@routecraft/routecraft'
import { fromFile, mcp } from '@routecraft/ai'
import { z } from 'zod'

const mail = z.object({ to: z.string(), subject: z.string(), body: z.string() })

export default craft()
  .id('send-mail')
  .title('Send mail')
  .description('Hold an email for review before it is sent')
  .input({ body: mail })
  .output({ body: mail })
  .from(mcp({
    ui: {
      html: fromFile(fileURLToPath(new URL('./send-mail.html', import.meta.url))),
      csp: { resourceDomains: ['https://cdn.jsdelivr.net'] },
      prefersBorder: true,
    },
  }))
  .transform((body) => body)

The view is a static HTML document. It loads the extension's App client, connects to the host, and draws from the tool result's structuredContent:

<!-- capabilities/send-mail.html -->
<!doctype html>
<html>
  <body>
    <dl id="mail"></dl>
    <script type="module">
      import { App } from 'https://cdn.jsdelivr.net/npm/@modelcontextprotocol/ext-apps@2/dist/src/app-with-deps.js'

      const app = new App({ name: 'send-mail', version: '1.0.0' })
      app.addEventListener('toolresult', ({ structuredContent }) => {
        const list = document.getElementById('mail')
        for (const [label, value] of Object.entries(structuredContent ?? {})) {
          list.append(Object.assign(document.createElement('dt'), { textContent: label }))
          const text = typeof value === 'string' ? value : JSON.stringify(value)
          list.append(Object.assign(document.createElement('dd'), { textContent: text }))
        }
      })
      await app.connect()
    </script>
  </body>
</html>

The csp lists are the only outside origins the host's sandbox lets the view reach; the example allows the CDN its client is loaded from.

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> (ui://routecraft/send-mail with the default name), derived from the MCP server's 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 (see Deferrable tools above) 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 })
toolstringYesTool name to invoke, or '*' to invoke the tool named on McpHeadersKeys.TOOL, which a route proxying a remote server carries on every exchange
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 the local registry of the MCP port that mcpPlugin provides, so the MCP server can list and invoke it via the MCP protocol:

import { MCP } from '@routecraft/ai'

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

const tools = Array.from(ctx.lookup(MCP)?.local.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 registries; direct routes never appear in the MCP tools/list response.

See Expose to an agent, Calling an MCP, and the MCP example.