mcpPlugin

← All plugins

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 stored in the context store under MCP_TOOL_REGISTRY. Local mcp() routes defined in the same context are not auto-populated into this registry; the MCP server reads them directly from the direct-adapter registry when responding to tools/list.

import { mcpPlugin, jwt } from '@routecraft/ai'
import type { CraftConfig } from '@routecraft/routecraft'

const config: CraftConfig = {
  plugins: [
    mcpPlugin({
      transport: 'http',
      port: 3001,
      auth: jwt({ secret: process.env.JWT_SECRET! }),
      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,
      restartDelayMs: 1000,
      restartBackoffMultiplier: 2,
    }),
  ],
}

export default config

Options:

OptionTypeDefaultDescription
namestring'routecraft'Server name exposed in MCP metadata (serverInfo.name)
titlestring--Human-readable display title (serverInfo.title)
versionstring'1.0.0'Server version
descriptionstring'Powered by Routecraft.dev'serverInfo.description; pass '' to omit
websiteUrlstring'https://routecraft.dev'serverInfo.websiteUrl; pass '' to omit
instructionsstring--Server-wide usage guidance on the initialize result; pass '' (or omit) to send none
iconsMcpIcon[]Routecraft logoserverInfo.icons, inherited by tools that set none of their own; pass [] to omit. See Server identity and branding.
transport'http' | 'stdio''stdio'Transport protocol for the MCP server
portnumber3001HTTP port (http transport only)
hoststring'localhost'HTTP host (http transport only)
authMcpHttpAuthOptions--Auth for the HTTP endpoint (http transport only; see below)
corsfalse | McpCorsOptionsloopback-onlyCORS for the HTTP transport. Default reflects loopback Origin headers; set to false to disable or { origin } to allowlist production browser clients. See Securing capabilities -> CORS.
toolsstring[] | (meta) => boolean--Allowlist of tool names to expose, or a filter function
clientsRecord<string, McpClientHttpConfig | McpClientStdioConfig>--Named remote MCP servers (see below)
maxRestartsnumber5Max automatic restarts for stdio clients before giving up
restartDelayMsnumber1000Initial delay before first restart attempt (ms)
restartBackoffMultipliernumber2Multiplier applied to delay on each successive restart
toolRefreshIntervalMsnumber60000Polling interval for HTTP client tool lists (0 = no polling)

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

HTTP server auth (McpHttpAuthOptions):

When auth is set and transport is 'http', every request to /mcp 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.

FieldTypeDescription
validator(token: string) => AuthPrincipal | null | Promise<AuthPrincipal | null>Validates the bearer token and returns the caller's identity, or null to reject with 401.

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:

FieldTypeRequiredDescription
kind'jwt' | 'oauth' | 'api-key' | 'basic' | 'custom'YesDiscriminator for the principal subtype
schemestringYesHTTP authentication scheme ('bearer', 'basic', 'api-key')
subjectstringYesStable identity for the caller (JWT sub, user ID, key ID)

Subtypes:

kindAdditional fields
'jwt'name?, email?, issuer?, audience?, scopes?, roles?, expiresAt?, claims (required)
'oauth'clientId (required), name?, email?, issuer?, audience?, scopes?, roles?, expiresAt?, claims?
'api-key'name?, expiresAt?
'basic'name?
'custom'name?, email?, roles?, scopes?, expiresAt?, claims?

The populated principal rides on the exchange as a single structured header (routecraft.auth.principal) and is exposed ergonomically via the ex.principal getter; read fields with ex.principal?.subject, ex.principal?.scopes, ex.principal?.claims, etc.

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

// Explicit algorithm
auth: jwt({ algorithm: 'HS384', secret: process.env.JWT_SECRET! })

RSA (RS256):

import fs from 'node:fs'

auth: jwt({
  algorithm: 'RS256',
  publicKey: fs.readFileSync('./public.pem', 'utf-8'),
})

Custom validator:

auth: {
  validator: async (token) => {
    const user = await db.verifyApiKey(token)
    if (!user) return null
    return {
      kind: 'api-key',
      scheme: 'api-key',
      subject: user.id,
      name: user.label,
    }
  },
}

OAuth 2.1 with oauth()

oauth() mounts a full OAuth 2.1 server flow that proxies to an upstream IdP. Pass a jwt config to let the factory handle JWKS fetching, signature verification, issuer and audience checks, and claim mapping (requires the optional peer dependency jose). For opaque tokens, introspection, or fully custom verification, pass your own verifyAccessToken callback instead.

Built-in JWT verification (recommended):

import { mcpPlugin, oauth } from '@routecraft/ai'

auth: oauth({
  issuerUrl: 'https://mcp.example.com',
  endpoints: {
    authorizationUrl: 'https://idp.example.com/authorize',
    tokenUrl: 'https://idp.example.com/token',
  },
  jwt: {
    jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
    issuer: 'https://idp.example.com',
    audience: 'https://mcp.example.com',
  },
  client: {
    client_id: 'my-mcp-server',
    redirect_uris: ['http://localhost:3000/callback'],
  },
})

issuer and audience are required, so the server cannot silently accept tokens from a different IdP or minted for a different resource. The factory maps standard JWT claims (sub, client_id, email, name, iss, aud, scope, roles, exp) to OAuthPrincipal fields automatically; the resolved principal surfaces on the structured routecraft.auth.principal exchange header and is exposed ergonomically via the ex.principal getter.

client accepts either a static OAuthClientInfo (matched on client_id; unknown IDs are rejected) or a supplier (clientId) => Promise<OAuthClientInfo | undefined> for dynamic lookup against a database or registry.

OAuthJwtConfig fields:

FieldTypeRequiredDescription
jwksUrlstring | URLYesJWKS endpoint the IdP publishes; keys are fetched and rotated by jose's createRemoteJWKSet
issuerstringYesExpected iss claim; tokens from other issuers are rejected
audiencestring | string[]YesExpected aud claim; the token must include at least one of these values
clockTolerancenumber | stringNoSkew tolerance applied to exp/nbf validation (seconds as a number, or a string like "5s"); default: no tolerance
claimsOAuthJwtClaimMappersNoPer-claim overrides for non-standard IdPs (see below)

OAuthJwtClaimMappers fields. Each maps a verified payload to the corresponding OAuthPrincipal field when the IdP uses non-standard claim names:

FieldDefault when omitted
subjectpayload.sub, then payload.client_id, then payload.azp
clientIdpayload.client_id, then payload.azp
scopesspace-split payload.scope

email, name, and roles are not mappable here. They are read from the standard claim names (email, name, roles) when present in the token. For identity fields that do not live in the bearer (most IdPs do not put them there), use the userinfo option on mcpPlugin({}): function variant for custom mappings, OIDC Discovery or an explicit URL for the standard /userinfo endpoint.

Claim overrides for non-standard IdPs:

jwt: {
  jwksUrl: 'https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys',
  issuer: 'https://login.microsoftonline.com/<tenant>/v2.0',
  audience: '<app-id>',
  claims: {
    subject: (p) => p.oid as string,
  },
}

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({
  issuerUrl: 'https://mcp.example.com',
  endpoints: {
    authorizationUrl: 'https://idp.example.com/authorize',
    tokenUrl: 'https://idp.example.com/token',
  },
  verifyAccessToken: async (token) => {
    const { payload } = await jwtVerify(token, jwks, {
      issuer: 'https://idp.example.com',
      audience: 'https://mcp.example.com',
    })
    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>,
    }
  },
  client: async (clientId) => await db.clients.findByClientId(clientId),
})

expiresAt is required by the MCP SDK's bearer middleware; omit it and every request is rejected with 401. Pass either jwt or verifyAccessToken, never both.

The client supplier (when you pass a function rather than a static object) is invoked per request by the OAuth proxy provider during every authorize/token/revoke call. Cache or preload registry reads so the hot path stays fast.

HTTP client config (McpClientHttpConfig):

FieldTypeRequiredDescription
urlstringYesFull URL of the remote MCP server
authMcpClientAuthOptionsNoAuth credentials sent on every request to this server

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

Stdio client config (McpClientStdioConfig):

FieldTypeRequiredDescription
transport'stdio'YesMust be 'stdio' to select subprocess mode
commandstringYesExecutable to spawn (e.g. 'node', 'npx')
argsstring[]NoArguments passed to the command
envRecord<string, string>NoEnvironment variables for the child process
cwdstringNoWorking directory for the child process

Stdio clients are spawned when the context starts and stopped on teardown. If the subprocess exits unexpectedly, the plugin automatically restarts it with exponential backoff (restartDelayMs * restartBackoffMultiplier ^ attempt). The restart counter resets after a successful reconnection.

See Running an MCP server, Calling an MCP, and Securing capabilities for usage guides.