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):
All other tool metadata (title, description, input / output schemas) comes from the route builder and is enforced framework-wide:
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/listadvertisesoutputSchemaas the derived uniononeOf: [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: falseresult whosestructuredContentis 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 asmetastays 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 aoneOfroot 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:deferredwith the deferral id, nevercompleted(a deferred run reported as finished is a false receipt) and neverfailedordeclined.
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:
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.
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:
mcp({ proxy: 'github:*' })as the source means this route is every tool of the clientgithub, exposed under the remote's own names with the prefixgithub_.mcp({ proxy: 'github:create_issue' })means this route is that one tool..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.- 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:
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().excludeon 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()andannotationsmerge over the remote's annotations per key. - The remote's JSON Schemas pass through to
tools/listand to an agent's catalogue; the remote validates the arguments at its door. A remote tool with anoutputSchemapublishes its result asstructuredContent, 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 theDeferredarm 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 registeredauth. A remote result flaggedisErrorfails the hop withAI2003, 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. uiis 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:
Rules the adapter enforces:
- The route must declare
.output({ body }); the view draws fromstructuredContent. Auiwithout it fails when the route subscribes with RC5003 naming the route. A malformeduithrows RC5003 whenmcp()is called. - The URI is
ui://<server name>/<route id>(ui://routecraft/send-mailwith the default name), derived from the MCP server'snameand the route id, so it cannot collide or drift. - The view is served behind the same auth as the tools and follows the
toolsfilter: 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 anisErrorresult carries nostructuredContent. Write the view to handle each it can meet. - The view renders after the tool has run. It is not a confirmation step; use
destructiveHintand the host's own permission prompt for that.
Options (McpClientOptions -- destination):
McpClientAuthOptions:
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.