Beyond the defaults
Calling an MCP
Call tools on external MCP servers from within a capability.
How it works
The mcp key in craft.config.ts connects your Routecraft context to one or more remote MCP servers. Once they are registered, you can call any tool on those servers using .to(mcp('server:tool')) or .enrich(mcp('server:tool')) inside any capability.
Install
bun add @routecraft/ai
Register remote servers
List the servers your capabilities need to reach under the mcp key's clients in craft.config.ts:
// craft.config.ts
import { defineConfig } from '@routecraft/routecraft'
import '@routecraft/ai'
export const craftConfig = defineConfig({
mcp: {
clients: {
browser: { url: 'http://127.0.0.1:8089/mcp' },
search: { url: 'http://127.0.0.1:9000/mcp' },
github: { url: 'https://api.githubcopilot.com/mcp/', auth: { token: process.env.GITHUB_TOKEN! } },
},
},
})
Each key under clients is the server alias you use in your capabilities.
Call a tool
Use the server:tool shorthand in .to() to send the exchange body as tool arguments and replace it with the result:
// capabilities/web-search/route.ts
import { craft, simple, log } from '@routecraft/routecraft'
import { mcp } from '@routecraft/ai'
export default craft()
.id('web-search')
.from(simple({ query: 'Routecraft documentation' }))
.to(mcp('search:web_search'))
.to(log())
Or use .enrich() with an aggregator such as only() to merge the result into the exchange body instead of replacing it:
// capabilities/enrich-order/route.ts
import { craft, http, only } from '@routecraft/routecraft'
import { mcp } from '@routecraft/ai'
export default craft()
.id('enrich-order')
.from(http({ path: '/orders/:id', method: 'GET' }))
.enrich(mcp('search:lookup_customer'), only((customer) => customer, 'customer'))
.to(http({ method: 'POST', url: 'https://crm.example.com/orders' }))
Bare .enrich(mcp(...)) behaves like .to(): the tool result replaces the body.
Custom argument mapping
By default, the exchange body is passed as-is to the tool. Use the args option to map the body to the exact shape the tool expects:
.to(mcp('browser:navigate', {
args: (exchange) => ({ url: exchange.body.targetUrl }),
}))
Full URL (no registration required)
If you only need to call a single external tool and do not want to register it globally, pass the URL directly:
.to(mcp({ url: 'http://127.0.0.1:8089/mcp', tool: 'navigate' }))
Guardrails: raw, guarded, or wrapped
A raw MCP tool carries no per-call policy. When an agent calls one, the credentials registered on the client are what reach the server; the agent does not forward the caller's principal to the MCP hop (this keeps the two trust boundaries separate: see Securing capabilities). So a raw tool has no identity check, no caching, and no timeout of its own. You add those on the Routecraft side, and there are three tiers to choose from.
Pick the lowest tier that covers what you need. The moment you need caching, a timeout, throttling, retry, a fallback, or an audit trail, you are at tier 3: a guard is a single predicate with no state and no clock, so it can answer "may John call this?" but it cannot hold a cache or a deadline.
Tiers 1 and 2 are covered on the agent plugin reference. For tier 3, put a route in front of the tool. A route per tool starts at a direct() endpoint, ends in the .to(mcp(...)) call you have already seen, and keeps the guardrails on the steps between; a route over a vendor's whole server starts at the proxy source instead, shown below.
// capabilities/github-create-issue/route.ts
import { mcp } from '@routecraft/ai'
import { craft, direct } from '@routecraft/routecraft'
export default craft()
.id('github-create-issue')
.authorize({ roles: ['maintainer'] }) // per-call principal check, before the source
.from(direct())
.to(mcp('github:create_issue'))
Hand the agent the governed route instead of the raw tool. The same underlying tool can be exposed both ways: wrap the ones that need policy, leave harmless read-only tools raw. A route per tool is the shape for a tool whose schema or name you want to change; for a vendor's whole server, or a tool you want exposed under the remote's own contract, a proxy route governs it without a route per tool, and an agent's MCP(server:tool) reference goes through it on its own.
agent({
tools: tools([
'Direct(github-create-issue)', // governed: authorized and auditable
'MCP(github:list_issues)', // raw: read-only, fine ungoverned
]),
})
Why a route and not a richer guard? A guard runs once and holds no state. Caching, timeouts, throttling, retries, and fallbacks each need something wrapped around the call with its own state and lifecycle, which is exactly what a route step is. A wrapped route gives you authorize(), error() fallbacks, cache(), throttle(), timeout(), retry(), and .tap(log()) for an audit trail, all without changing how the agent consumes the tool. The route is the only place that behaviour can ever live.
Governing a remote server's tools
A vendor's server lists dozens of tools, and wrapping each in a route is the slow way to put policy on them. A route can instead be the tools of a client registered under the mcp key's clients, or one of them: the source names the client, the hop forwards the call, and the chain and the steps between are the policy.
// capabilities/github/route.ts
import { mcp, McpHeadersKeys, type McpToolAnnotations } from '@routecraft/ai'
import { craft, type Exchange } from '@routecraft/routecraft'
const readOnlyTool = (ex: Exchange) =>
(ex.headers[McpHeadersKeys.ANNOTATIONS] as McpToolAnnotations | undefined)?.readOnlyHint === true
export default craft()
.id('github')
.authorize({ scopes: ['github:read'] })
.cache({ ttl: '2m', when: readOnlyTool })
.from(mcp({ proxy: 'github:*' }))
.to(mcp('github:*'))
Every tool the github client lists is now served by your MCP server as github_<tool>, authorized on the caller's principal, with the read-only ones cached; the exceptions are the server's own tools filter, a name a local route already holds, and a prefixed name that is not a valid tool name, each skipped with a warning. A tool that needs its own shaping gets its own route, which wins over the star route for that tool while it is live; if the tool must not fall back to the star route when its own route is off, leave it out of the group with mcp({ proxy: 'github:*', exclude: ['create_issue'] }):
// capabilities/github-create-issue/route.ts
import { mcp } from '@routecraft/ai'
import { craft } from '@routecraft/routecraft'
export default craft()
.id('github-create-issue')
.authorize({ roles: ['maintainer'] })
.from(mcp({ proxy: 'github:create_issue' }))
.transform((args) => {
const issue = args as { title: string; body?: string }
// A compact uppercase IBAN never reaches GitHub; a real redaction normalises first.
return { ...issue, body: issue.body?.replace(/\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g, '[iban]') }
})
.to(mcp('github:create_issue'))
Steps above the hop see the arguments; throw to refuse, return to rewrite. Steps below it see the result, with the same two moves. A .defer() before the hop, or an approval hook on a tag, parks the call until a person answers. The caller's principal selects policy and never reaches the remote; the hop authenticates with the client's registered auth.
An agent that lists MCP(github:create_issue) goes through that route too, so your own agent is under the same policy as an external client. The mcp adapter reference has the options, the headers a proxied exchange carries, the naming rules, and the migration from a hand-written wrapping route or a former mcpPlugin({ proxy }) entry.
Related
Expose to an agent
Run your own capabilities as MCP tools for AI clients.
MCP tool
A copyable capability exposed as an MCP tool, and one that calls a remote MCP server.
mcp
The mcp() adapter: every option for serving a tool and for calling one.