mcpPlugin
import { mcpPlugin } from '@routecraft/ai'
Starts an MCP server so capabilities exposed with .from(mcp(...)) are reachable by external MCP clients. Also registers named remote MCP clients (HTTP or stdio subprocess) so capabilities can call external MCP servers by a short server id. Required when any capability uses mcp() as a source.
Tools discovered from remote MCP servers (stdio clients and HTTP clients) are collected into an McpToolRegistry held by the MCP port the plugin provides (context.lookup(MCP)?.tools). Local mcp() routes defined in the same context are not auto-populated into this registry; they register in the port's separate local registry, which the MCP server reads when responding to tools/list.
import { mcpPlugin, jwt } from '@routecraft/ai'
import type { CraftConfig } from '@routecraft/routecraft'
export const craftConfig: CraftConfig = {
servers: {
default: { port: 3001 },
},
plugins: [
mcpPlugin({
transport: 'http',
auth: jwt({
secret: process.env.JWT_SECRET!,
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
}),
clients: {
browser: {
url: 'http://127.0.0.1:8089/mcp',
auth: { token: process.env.BROWSER_MCP_TOKEN! },
},
search: { url: 'http://127.0.0.1:8090/mcp' },
filesystem: {
transport: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
},
},
maxRestarts: 5,
restartDelay: 1000,
restartBackoffMultiplier: 2,
}),
],
}
Options:
Client names may not be empty, contain __, or end in _:
The key you register a client under becomes the server segment of the mcp__<server>__<tool> name agents see, and that name is split at the first __ after the prefix. Three shapes break that split, and mcpPlugin rejects all of them with RC5003 at startup:
The collision is the reason this is rejected rather than merely warned about: two different clients can compose the same tool name, the resolved tool map is keyed by that name with later-wins, so one silently replaces the other and the model's call reaches the wrong client.
A single underscore inside the name is fine (my_company_api), and because only the server half is constrained, a remote may still expose tools whose own names contain __ (mcp__github__issues__create resolves correctly).
The rule applies whenever the client is registered, including contexts with no agent in them. mcpPlugin() validates its options at construction, before it can know whether an agent will later join the same context, so the constraint is namespace-wide rather than conditional on how the client is consumed.
Logging when transport is 'stdio':
The stdio transport uses stdout as the protocol channel. Routecraft's logger defaults to stdout, so logs will corrupt the protocol stream unless you redirect them. When running an MCP server over stdio, always pass one of:
--log-file <path>-- write logs to a file--log-level silent-- disable logging entirely
On run, both belong before the file (craft run --log-level silent ./mcp-server.ts). Anything after run <file> is pass-through and reaches the route rather than the logger, which leaves the protocol stream exactly as corrupted as passing nothing.
HTTP server auth (McpHttpAuthOptions):
When auth is set and transport is 'http', every request to /mcp, and to each mount below it, must include a valid Authorization: Bearer <token> header. The auth object requires a validator function that receives the raw bearer token and returns an AuthPrincipal on success or null to reject. The principal is made available on exchange headers so routes can read the caller's identity.
When auth is omitted, the named server's validator (if any) is inherited as the wall. auth: false removes the wall: the surface never demands credentials, requires no scopes, and issues no challenge, but the inherited validator stays reachable, so a valid token still attaches a principal to tool calls (which is how a tool's .authorize() admits on a public mount), an invalid or expired one is treated as absent, and a validator infrastructure failure still maps to 500.
A tool's .authorize() refusing the caller answers the call with a fixed reason rather than the error message: insufficient permissions, the missing scopes, an expired credential, or, on a mount with a validator that served the call anonymously, that the tool needs an authenticated caller. Arguments the tool's .input() schema refuses come back with each issue's path and message, and a result body that breaks the advertised output schema names the failing fields; every other failure carries the tool name and the error code only. See Failed calls for the texts.
AuthPrincipal:
AuthPrincipal is a discriminated union on the kind field. Every subtype carries kind, scheme, and subject; other fields live on the subtype that gives them meaning. Narrow on kind to reach scheme-specific data.
Shared fields on every subtype:
Subtypes:
The populated principal rides on the exchange as a single structured header (routecraft.auth.principal) and is read with the auth plugin's facet in a route; read fields with ex.auth.principal?.subject, ex.auth.principal?.scopes, ex.auth.principal?.claims, etc. Code that holds a plain exchange (an adapter, a hook) reads it with principalOf(exchange).
Server identity and branding
When a client like Claude adds your server, it renders the server's identity from serverInfo, returned by server/discover for 2026-07-28 clients and by the initialize handshake for 2025-era ones. Configure it on the mcp key; the option table above has each field's default and how to suppress it:
// craft.config.ts
import { defineConfig } from '@routecraft/routecraft'
import '@routecraft/ai'
export const craftConfig = defineConfig({
mcp: {
name: 'acme-bot', // serverInfo.name (machine id)
title: 'Acme Bot', // serverInfo.title (display name)
version: '2.1.0', // serverInfo.version
description: 'Acme operations over MCP.', // serverInfo.description
websiteUrl: 'https://acme.example.com', // serverInfo.websiteUrl
instructions: 'Call orders_search before orders_refund.', // server instructions, not serverInfo
icons: [
{ src: 'https://acme.example.com/icon.svg', mimeType: 'image/svg+xml' },
{ src: 'data:image/png;base64,...', mimeType: 'image/png', sizes: ['48x48'], theme: 'light' },
],
},
})
instructions is server-wide guidance the client may add to the model's context (advisory per the spec). It complements each tool's own .description(), which is the per-tool equivalent.
Per-tool icons and inheritance
A capability can carry its own icon via the mcp() source. The icon shape follows the MCP Icon spec (src, optional mimeType, sizes as a string array, and an optional theme):
craft()
.id('orders_search')
.description('Search orders')
.from(mcp({
annotations: { readOnlyHint: true },
icons: [{ src: 'https://acme.example.com/search.svg', mimeType: 'image/svg+xml', sizes: ['48x48'] }],
}))
Icons resolve with the same rule at both levels: omit icons to inherit (a tool with no icon of its own shows the server's icon, including the Routecraft default), set icons: [...] for a custom icon, or set icons: [] to show none.
Built-in jwt() helper
The jwt() helper creates a validator that verifies JWT signatures, checks expiry, and maps standard claims to AuthPrincipal fields. Zero dependencies (uses node:crypto).
import { mcpPlugin, jwt } from '@routecraft/ai'
HMAC (HS256 / HS384 / HS512):
auth: jwt({
secret: process.env.JWT_SECRET!,
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
})
// Explicit algorithm
auth: jwt({
algorithm: 'HS384',
secret: process.env.JWT_SECRET!,
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
})
RSA (RS256):
import fs from 'node:fs'
auth: jwt({
algorithm: 'RS256',
publicKey: fs.readFileSync('./public.pem', 'utf-8'),
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
})
issuer and audience are required on every jwt() / jwks() call: without them the server would accept a token minted by a different IdP, or for a different resource.
Custom validator:
auth: {
validator: async (token) => {
const user = await db.verifyApiKey(token)
if (!user) throw new Error('unknown key')
return {
kind: 'api-key',
scheme: 'api-key',
subject: user.id,
name: user.label,
}
},
}
OAuth with oauth()
The MCP server is an OAuth 2.0 Resource Server. oauth() verifies bearer tokens, enforces required scopes, and advertises the Authorization Server through RFC 9728 metadata so clients run the authorization flow directly against your IdP. Routecraft mounts no /authorize, /token, /register or /revoke endpoints of its own.
OAuthFactoryOptions fields:
JWKS-backed verification (recommended):
import { mcpPlugin, oauth, jwks } from '@routecraft/ai'
auth: oauth({
verify: jwks({
jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
}),
requiredScopes: ['mcp:invoke'],
})
issuer and audience are required on jwks() / jwt(), so the server cannot silently accept tokens from a different IdP or minted for a different resource. Standard claims (sub, client_id, email, name, iss, aud, scope, roles, exp) map to OAuthPrincipal fields automatically; the resolved principal surfaces on the structured routecraft.auth.principal exchange header and read with ex.auth.principal in a route (principalOf(exchange) elsewhere). For non-standard IdPs, pass claims mappers to jwks() / jwt(); see Securing capabilities.
Passing jwks(...) straight to auth works identically. Reach for oauth() when you want requiredScopes enforcement or an explicit issuer.
Custom verification (opaque tokens, introspection, etc.):
import { mcpPlugin, oauth } from '@routecraft/ai'
import { jwtVerify, createRemoteJWKSet } from 'jose'
const jwks = createRemoteJWKSet(new URL('https://idp.example.com/.well-known/jwks.json'))
auth: oauth({
issuer: 'https://idp.example.com',
verify: async (token) => {
const { payload } = await jwtVerify(token, jwks, {
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
})
if (typeof payload.exp !== 'number') throw new Error('token has no exp')
return {
kind: 'oauth',
scheme: 'bearer',
subject: payload.sub as string,
clientId: payload['client_id'] as string,
expiresAt: payload.exp,
claims: payload as Record<string, unknown>,
}
},
})
expiresAt is required on a principal returned through oauth(): a principal without a finite numeric expiry has no bounded validity window and is refused. A principal whose expiry has already passed is refused at the gate whichever auth mode produced it. The boundary is inclusive and compared in whole seconds, so a principal whose expiresAt equals the current second is already expired, matching RFC 7519 section 4.1.4.
verify runs on every request. Revision 2026-07-28 is stateless, so there is no session in which a past verification could be cached; keep introspection calls fast or cache them yourself.
Proxying client tools
A client's tools are re-exposed through this server by a route, not by a plugin option: .from(mcp({ proxy: 'server:*' })) for every tool of a registered client, .from(mcp({ proxy: 'server:tool' })) for one, with .to(mcp('server:*')) as the hop and the route's chain and steps as the policy. The tools the client lists appear as <client>_<tool> by default (prefix changes that, exclude leaves named tools out of the group, name on an exact route renames its one tool), follow the client's tool refresh and stdio restarts, and are served exactly as a .from(mcp()) route's tool is: same tools filter, same decline and failure mapping, same events with proxied: true and the client and remote tool named; a name a local route already holds, or one that is not a valid tool name, is skipped with a warning under the naming rules. Over HTTP the same tools are also served under the client's own URL by their remote names; see Mounts. 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.
Mounts
Over HTTP the server answers at path and at one segment below it per name it knows, so a client connects to the slice it needs instead of filtering the whole catalogue. Nothing configures this: the segments are the plugin's name and the keys of clients.
<name> is the name option: mcpPlugin({ name: 'eywa' }) serves its own tools at /mcp/eywa. Over HTTP a client id equal to the server name is refused at bind with RC5003, since both would mount at one path, and so is a name or client id that is not one path segment ([A-Za-z0-9_.-]+, and neither . nor ..); stdio serves the root alone and checks neither. Every registered client has its mount whether or not a route proxies it yet, so a route that starts later is served without a restart; until then the mount lists no tools. A path below path that is no mount is a 404. A reverse proxy that forwards exactly /mcp leaves the mounts unreachable, so forward the prefix.
The mounts share auth, cors, browserOrigins and the tools filter. The filter judges a proxied tool by the name the root lists it under, on every mount, so hiding docs_search at the root hides search on /mcp/docs and an allowlist names the prefixed names. Each mount has its own RFC 9728 document at /.well-known/oauth-protected-resource/mcp/<segment>, whose resource is resource.url with the segment appended, so a client that discovers through the mount it connected to is told that mount's identity, and asks its authorization server for a token for that resource; a validator bound to one audience therefore has to accept each mount URL it serves (audience takes an array). serverInfo and instructions are the server's, the same on every mount. MCP Apps views belong to local tools and are served on the root and on /mcp/<name>, not on a client mount. The stdio transport has one surface, the root. A tool-call event for a call that arrived below the root carries the segment as mount (events), since on a client mount tool is the remote's bare name and says nothing about which server was called.
See Expose to an agent, Calling an MCP, and Securing capabilities for usage guides.