Skip to content

MCP (Model Context Protocol) ​

@basaltkit/mcp turns a Basalt app into an MCP server — and lets it act as a client. Opt-in routes become tools an AI agent can call, over HTTP (any adapter) or stdio. Crucially, a tool call runs through the same neutral request pipeline as HTTP, so validation, tenancy and auth apply unchanged — MCP is just another way in, not a bypass.

Runtime, not codegen

This is a runtime package: it exposes your app's routes to agents in production. It's separate from the dev-only @basaltkit/ai / @basaltkit/ai-mcp layer (which exposes dev workflows to your editor), and it's built on the zero-dependency @basaltkit/mcp-core. Basalt speaks MCP's JSON-RPC directly — no external SDK.

Where MCP fits ​

Four packages speak MCP, each with one job — this page is the last row:

LayerPackageRoleRuntime?
Intelligence@basaltkit/aiThe basalt ai CLI: analyze, doctor, plan, make, reviewdev-only
Dev bridge@basaltkit/ai-mcpExposes those dev workflows to your editor over MCPdev-only
Wire@basaltkit/mcp-coreZero-dependency protocol + generic server + transportsshared
Runtime surface@basaltkit/mcpThis page — opt-in routes become tools for agentsruntime

Expose routes as tools ​

Opt a route in with meta.mcp, register mcpPlugin, and add mcpRoutes() to your adapter:

ts
import { createApp } from '@basaltkit/core'
import { fastifyPlugin } from '@basaltkit/fastify' // or express / hono
import { mcpPlugin, mcpRoutes } from '@basaltkit/mcp'
import { route } from '@basaltkit/http'
import { z } from 'zod'

const routes = [
  route({
    method: 'POST', url: '/projects',
    meta: { mcp: true },                       // → tool `post_projects`
    body: z.object({ name: z.string().min(3) }),
    async handler({ body }) { return db.projects.create(body) },
  }),
  route({
    method: 'GET', url: '/projects/:id',
    meta: { mcp: { name: 'get_project', description: 'Fetch a project by id' } },
    params: z.object({ id: z.string() }),
    async handler({ params }) { return db.projects.find(params.id) },
  }),
]

await createApp({
  plugins: [
    mcpPlugin({ routes, serverInfo: { name: 'my-app', version: '1.0.0' } }),
    fastifyPlugin({ routes: [...routes, ...mcpRoutes()] }), // POST /mcp
  ],
}).boot()
  • Opt-in only — routes without meta.mcp are never exposed. meta.mcp is either true or { name?, description? }.
  • Input schema is generated from the route's params + query + body Zod schemas, merged into one flat object.
  • Same pipeline — a tools/call runs enrichers, guards and validation before the handler. The tool request inherits an allowlist of the caller's headers (authorization, cookie, x-api-key, x-tenant-id, host, accept-language, user-agent — extend it with mcpPlugin({ forwardHeaders })), the client ip (request.ip), request.routePattern (the tool route's template) and the concrete request.url (/projects/p%201?q=x, not /projects/:id). Everything else — x-request-id, if-none-match, forwarding and hop-by-hop headers — is dropped.
  • Status is honoured — a handler that replies reply.code(403) (any status ≥ 400) produces a tool result with isError: true.
  • Cancellation — notifications/cancelled answers the call as cancelled at once; a long handler can stop early by checking toolSignal(request)?.aborted. Over HTTP the cancel may arrive in a later POST of the same session.
  • Filtered listing — tools/list over /mcp hides the tools the caller statically cannot use; see What tools/list shows.

Guards apply — and must be enforceable

A route with meta.auth (or meta.can / meta.teamRole) keeps that guard when invoked as a tool: an unauthenticated tools/call gets the same UNAUTHORIZED error body as an unauthenticated HTTP request, carried in the tool result with isError: true. The flip side: if any route declares meta.auth and no authPlugin is registered, the app refuses to boot with UnguardedRouteMetaError (HTTP_UNGUARDED_ROUTE_META) — see Security. Over HTTP, pass Authorization / tenant headers on the POST /mcp request; over stdio, pass static headers to serveMcpStdio.

What the model sees when a tool fails ​

The MCP client is a language model — and anyone who can read or steer its context (a prompt injection, a transcript, a log of the conversation). Treat a tool result as a response sent to an untrusted client. When a tool's handler, guard or validation throws, the result carries isError: true and the same { code, message, details? } an HTTP client would get, with these boundaries:

ChannelReaches the model?Notes
code, messageyesA toolkit 500 or an expose: false error sends a neutral message, exactly as over HTTP
detailsyes — redactedSanitised for shape, then passed through redactErrorDetails (default redactSensitiveDetails: the value of any key naming a secret — password, resetToken, apiKey, secret, sessionId, … — becomes '[REDACTED]'; booleans/null kept)
internalDetailsneverLog-only: handed to reportError (default: the console reporter) with the untouched error
stack, cause, unexpected exception textneverUnexpected errors become INTERNAL_ERROR
a body your handler sends itself (reply.code(4xx).send(body))yes — verbatimThat is the route's response contract; nothing is redacted there
ts
mcpPlugin({
  routes,
  redactErrorDetails: (details) => ({ failed: details.failed }), // your own allowlist
  reportError: (report) => logger.warn(report, 'tool call failed'),
})

// Per route: override (or disable with `false`) for that tool only.
route({ method: 'POST', url: '/kyc', meta: { mcp: { redactErrorDetails: false } }, handler })

Redaction is defence in depth, not a licence: keep details public by construction and put operator-only data in internalDetails. The HTTP adapters do not redact by default (their output is unchanged); use toErrorResponse(error, { redactDetails }) in your own adapter or error handler for the same filter.

Tool schemas & arguments ​

Tool names come from the route's method and path: GET /skills → get_skills, GET /skills/:id → get_skills_by_id, POST /skills → post_skills. Override with meta: { mcp: { name: 'my_tool' } }.

Input schema is generated from the route's params, query and body Zod schemas, merged into one flat object with the right required fields — so the client knows exactly what to send.

Argument coercion. MCP clients and LLMs frequently send numbers and booleans as strings ("7", "true"). Before validation the bridge coerces each argument to the scalar type its Zod field declares, so a z.number() field accepts "7" and receives 7. Non-coercible strings are left as-is so genuine validation errors still surface.

Structured output. A tool result always carries the handler's return value as text (content), and — only when that value is a JSON object — also as structuredContent. Handlers returning a top-level array or primitive (e.g. a list endpoint) put the data in content only, because MCP requires structuredContent to be an object.

Schema conversion uses Zod's own z.toJSONSchema, so a tool's input schema is described to the client exactly as Zod describes it. Zod 4 is required — see the note on the peer dependency in the package's README.

stdio & Claude Desktop ​

For local agents (Claude Desktop, IDEs), serve the same server over stdio. Use a dedicated entry — not your HTTP server.ts — that boots the app and serves stdio, with no HTTP listen and nothing printed to stdout:

ts
// src/mcp-stdio.ts
import { serveMcpStdio } from '@basaltkit/mcp'
import { buildApp } from './app.js'

const app = await buildApp({ logLevel: 'silent' }).boot() // includes mcpPlugin
serveMcpStdio(app) // newline-delimited JSON-RPC on stdin/stdout

Wire Claude Desktop to it (claude_desktop_config.json):

jsonc
{
  "mcpServers": {
    "my-app": {
      "command": "/absolute/path/to/node",
      "args": ["/absolute/path/to/dist/mcp-stdio.js"]
    }
  }
}

Getting this right in practice:

  • Build first. Claude Desktop runs the compiled dist/mcp-stdio.js, so run your build after every change. For a dev loop, run the TS entry with node --import tsx src/mcp-stdio.ts instead.
  • Use an absolute node path. GUI apps on macOS don't inherit your shell PATH, so node/npx/pnpm may not be found — point command at the absolute binary (from which node).
  • Keep stdout clean. stdout is the JSON-RPC channel: set logLevel: 'silent' and remove any console.log in your handlers — one stray line corrupts the protocol.
  • Load your env. The spawned process has no shell, so load your .env (Node's process.loadEnvFile(), or pass vars via the config's env field), and make sure the DB/services the app boots against are reachable.
  • A silent stdio server is normal. Run alone it just waits for input — it is meant to be spawned by a client, not run by hand. Pipe a message to check it: echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/mcp-stdio.js.

Consume external MCP servers (client) ​

The runtime side of server + client — point a client at any MCP server:

ts
import { McpClient, HttpClientTransport, StdioClientTransport } from '@basaltkit/mcp'

const client = new McpClient(new HttpClientTransport('https://host/mcp'))
await client.connect()
const { tools } = await client.listTools()
const result = await client.callTool('get_project', { id: 'p1' })

// …or spawn a stdio server
const local = new McpClient(new StdioClientTransport({ command: 'some-mcp-server' }))
await local.connect()

A spawned stdio server does not inherit your app's environment: only a non-secret allowlist (PATH, HOME, locale, temp dirs — DEFAULT_INHERITED_ENV) plus the explicit env reaches it, so APP_SECRET, DATABASE_URL and provider keys stay in your process. Pass inheritEnv: ['GITHUB_TOKEN'] to forward named variables, or inheritEnv: true to deliberately forward everything.

If the command can't be spawned (ENOENT) or the server exits, calls in flight reject instead of crashing your process, and the next call spawns it afresh. A request the server never answers rejects after timeoutMs (default 60 000 ms).

Register servers with a plugin ​

mcpClientPlugin wires named external servers into the container — it connects them at boot and closes them on shutdown, so any part of the app can use their tools through the MCP_CLIENTS registry:

ts
import { mcpClientPlugin, MCP_CLIENTS } from '@basaltkit/mcp'

createApp({
  plugins: [
    mcpClientPlugin({
      servers: {
        search: { type: 'http', url: 'https://search.example/mcp' },
        files: { type: 'stdio', command: 'mcp-files', args: ['--root', '.'] },
      },
    }),
  ],
})

// anywhere with the container:
const clients = container.get(MCP_CLIENTS)
const { tools } = await clients.listTools('search')
const result = await clients.callTool('search', 'query', { q: 'basalt' })

Connections are lazy-safe: callTool / listTools connect on demand, so eager: false defers connecting until first use.

Transports ​

TransportServerClientAdapters
HTTP (POST /mcp)mcpRoutes()HttpClientTransportfastify · express · hono
stdioserveMcpStdio()StdioClientTransportlocal process

The HTTP transport is a neutral route(), verified on all three adapters — the same tool surface regardless of the server underneath.

It is hardened for browsers: a request whose Origin is neither same-origin nor listed in mcpRoutes({ allowedOrigins }) gets 403, and the body must be sent as application/json (415 otherwise), so a cross-site page can never drive a tool with a visitor's cookies. Non-browser clients send no Origin and are unaffected. By default initialize and tools/list are anonymous (tool calls still run each route's guards); mcpRoutes({ auth: true }) requires an authenticated caller for the endpoint itself. JSON-RPC batches are accepted.

Sessions and cancellation ​

/mcp speaks Streamable-HTTP sessions by default. A successful initialize answers with an Mcp-Session-Id header; every later POST must carry it:

RequestAnswer
initialize200 + a new Mcp-Session-Id (a failed initialize opens none)
any other message without the header400 — send initialize first
an unknown, expired or foreign session id404 — the client re-initializes (spec behaviour)
DELETE /mcp with the header204, the session ends (404 if it was not live)

All requests of a session share one cancellation scope, so a notifications/cancelled POSTed while the call runs cancels it — and a different session, even one that guesses the request id, never can. A session is bound to the caller that opened it: the authenticated ctx().user (in its tenant) or, for an anonymous caller, a keyed fingerprint of its Authorization header (an API key the auth plugins accepted already resolved a user). The same id presented by anyone else is a 404. Sessions expire after 30 minutes idle, and at most 1000 live at once (the least recently used is evicted — its client just re-initializes): mcpRoutes({ sessions: { ttlMs, maxSessions } }).

HttpClientTransport (and so McpClient/mcpClientPlugin) handles the header for you and ends the session on close().

Sessions live in process memory

Behind several replicas, route a session to one replica (sticky sessions on Mcp-Session-Id), or run stateless with mcpRoutes({ sessions: false }) — each POST is then its own session and a cancel only reaches calls of the same request. A browser client on another origin must be allowed to read the header: add Mcp-Session-Id to your CORS exposeHeaders.

What tools/list shows ​

With mcpRoutes({ listVisibleOnly }) (default true), tools/list leaves out the tools the caller statically cannot use. Only side-effect-free checks decide — the route guards never run for a listing, so listing consumes no rate limit and writes no audit or denial record:

Hidden whenDecided by
the route has meta.auth and the caller has no ctx().userbuilt in, when a guard claims auth (e.g. authPlugin); under an edge-auth waiver nothing is hidden
the route has meta.teamRole and the caller does not hold that role (or a higher one) in the current tenantteamsPlugin's visibility check (one membership read)
the route has meta.can and the caller lacks one of its permissions (RBAC, current scope; superAdmin short-circuits)permissionsPlugin's visibility check (grant reads — no permission:denied record)
any key whose plugin registers a check in http:route-visibilitythat plugin's RouteVisibilityCheck

Not filtered — listed, and refused on call: mfa, scopes, subscribed/feature, audiences, rate limits and anything a handler checks itself (e.g. a policy it runs on a loaded resource with authorize(user, permission, resource) — there is no resource at listing time), and a meta.can resource requirement decided by a policy (its loader never runs on a listing; plain permissions beside it still filter). Visibility is never authorization: tools/call still runs every guard, for listed and unlisted tools alike. listVisibleOnly: false lists every opted-in tool. stdio listings are never filtered (there is no per-request caller).

On an exposed deployment, give /mcp its own rate-limit budget: mcpRoutes({ rateLimit: { limit: 30, windowMs: 60_000 } }) stamps meta.rateLimit on the route, and securityPlugin enforces it in a dedicated bucket. A tool route's own meta.rateLimit is enforced by a route guard, so it applies to tool calls through /mcp too, keyed by the /mcp caller's ip, which the tool request inherits. (Auth and guards run identically on both paths.)

Options reference ​

The tables below are the complete public options of the four entry points.

mcpPlugin(options) ​

OptionTypeDefaultWhy
routesBasaltRoute[]— (required)The routes scanned for meta.mcp — typically the same array you pass the adapter
serverInfo{ name: string; version: string }{ name: 'basalt', version: '0.1.0' }What initialize reports to clients
filter(route: BasaltRoute) => booleanexpose every opted-in routeA deployment-level gate on top of meta.mcp (e.g. hide admin routes in one environment)
forwardHeadersstring[]noneExtra request headers a tool call inherits, on top of DEFAULT_FORWARDED_HEADERS (e.g. a custom tenant header); every other header is dropped
redactErrorDetailsErrorDetailsRedactor | falseredactSensitiveDetailsFilters a thrown error's public details before they enter a tool result (see What the model sees); false sends them as HTTP would. A route overrides it with meta.mcp.redactErrorDetails
reportErrorHttpErrorReporter | falseconsole reporterReceives every error a tool call throws, internalDetails included; false reports nothing

mcpRoutes(options) ​

OptionTypeDefaultWhy
pathstring'/mcp'Where the JSON-RPC POST endpoint mounts
rateLimit{ limit: number; windowMs: number }noneStamps meta.rateLimit on /mcp (enforced by securityPlugin in a dedicated bucket) — the budget for all tool traffic; a tool route's own meta.rateLimit applies on top
allowedOriginsstring[] | '*'same-origin onlyBrowser origins allowed to call /mcp; a foreign Origin gets 403. Requests without Origin are unaffected. '*' disables the check
authbooleanfalseSets meta.auth on /mcp (enforced by authPlugin) so even initialize/tools/list need an authenticated caller
metaRecord<string, unknown>noneExtra meta for the /mcp route (e.g. { can: 'mcp:use' })
listVisibleOnlybooleantrueHide from tools/list the tools the caller statically cannot use — pure checks only (see What tools/list shows)
sessionsfalse | { ttlMs?: number; maxSessions?: number }on — 30 min idle, 1000 liveMcp-Session-Id sessions: required after initialize, bound to the caller, scope cross-POST cancellation; also mounts DELETE <path>. false = stateless

serveMcpStdio(app, options) ​

OptionTypeDefaultWhy
headersRecord<string, string>{}Static headers applied to every tool call — stdio has no per-request headers, so this is how a local agent carries a service token/tenant
inputNodeJS.ReadableStreamprocess.stdinInject a stream in tests
output{ write(chunk: string): unknown }process.stdoutInject a sink in tests
maxConcurrentRequestsnumber16Requests in flight at once on the connection; one more gets a -32000 (SERVER_BUSY) error. Notifications are never refused
maxLineLengthnumber4 MiBLongest accepted message line

Returns a handle whose close() detaches the stdin listener.

mcpClientPlugin(options) ​

OptionTypeDefaultWhy
serversRecord<string, { type: 'http'; url; headers? } | { type: 'stdio'; command; args?; env?; cwd?; inheritEnv? }>— (required)Named external servers registered under MCP_CLIENTS. A stdio server inherits only DEFAULT_INHERITED_ENV plus env; inheritEnv: string[] | true widens that
eagerbooleantrueConnect all servers at boot (fail fast) vs. lazily on first callTool/listTools

Failure modes & troubleshooting ​

Tool-level failures are not protocol errors: a handler/guard/validation error comes back as a normal result with isError: true, whose text is the same error body HTTP would have returned (e.g. { "code": "UNAUTHORIZED", … }). Protocol errors use JSON-RPC codes:

SymptomCauseFix
Boot throws UnguardedRouteMetaError (HTTP_UNGUARDED_ROUTE_META)A route declares meta.auth/meta.can/meta.teamRole and no plugin enforces itRegister authPlugin / permissionsPlugin / teamsPlugin — see Security
isError: true with an UNAUTHORIZED/FORBIDDEN bodyThe tool's route is guarded and the call carried no (or bad) credentialsSend Authorization/tenant headers with POST /mcp, or serveMcpStdio(app, { headers })
JSON-RPC -32602 Unknown tool: …Tool name not registered — route missing meta.mcp, excluded by filter, or renamedCheck tools/list; remember overrides via meta.mcp.name
JSON-RPC -32601 Method not foundThe client called an MCP method the server doesn't implementOnly initialize, ping, tools/list, tools/call (plus resources/prompts when registered) exist
A tool call returns RATE_LIMITED sooner than expectedThe tool route's own meta.rateLimit applies through /mcp too (per caller ip)Raise the route's budget, or pass a key to securityPlugin({ rateLimit })
403 MCP_ORIGIN_FORBIDDEN from POST /mcpA browser sent a cross-origin requestAdd the page's origin to mcpRoutes({ allowedOrigins })
415 from POST /mcpThe body was not sent as Content-Type: application/jsonSend application/json (MCP clients do)
400 Mcp-Session-Id header requiredA message other than initialize arrived without a sessionSend initialize first and echo its Mcp-Session-Id (spec clients do), or mcpRoutes({ sessions: false })
404 Session not foundThe session expired, was evicted or ended, the process restarted, another replica answered — or a different caller presented itRe-initialize; behind replicas use sticky sessions
A tool is missing from tools/list but callableThe caller statically fails its meta.auth/meta.teamRole/meta.can (listing hides it)Expected; mcpRoutes({ listVisibleOnly: false }) lists everything
Over stdio, -32000 Too many requests in flightMore than maxConcurrentRequests calls at once on the connectionWait for answers, or raise serveMcpStdio(app, { maxConcurrentRequests })
A tool reads a header that arrives undefinedThe header is not in the forwarded-header allowlistmcpPlugin({ forwardHeaders: ['x-my-header'] })
Claude Desktop shows a broken/dead serverSomething printed to stdout — it is the JSON-RPC channellogLevel: 'silent', remove console.log; see the stdio checklist above
'[REDACTED]' in a tool error's detailsThe key names a secret and the default redactErrorDetails masked itRename the key if it is not a secret, move secrets to internalDetails, or pass your own redactErrorDetails
202 response from POST /mcp with empty bodyThe message was a JSON-RPC notification — by spec it gets no replyExpected behaviour, not an error

Testing with the MCP Inspector ​

The MCP Inspector connects to your server and lets you list and call tools interactively — a visual studio for MCP:

bash
# Web UI (opens a browser):
npx @modelcontextprotocol/inspector /absolute/node dist/mcp-stdio.js

# Headless CLI:
npx @modelcontextprotocol/inspector --cli /absolute/node dist/mcp-stdio.js --method tools/list
npx @modelcontextprotocol/inspector --cli /absolute/node dist/mcp-stdio.js \
  --method tools/call --tool-name get_skills

Over HTTP, point it at your POST /mcp endpoint instead.

Try it in the playground ​

The repo's apps/playground opts three routes into MCP — create_project, list_projects, get_project — and ships a stdio entry. Point Claude Desktop at it:

jsonc
// claude_desktop_config.json
{
  "mcpServers": {
    "basalt-playground": {
      "command": "pnpm",
      "args": ["--filter", "playground", "mcp:stdio"]
    }
  }
}

Logging is silenced in that entry because stdout is the JSON-RPC channel. Over HTTP, the same tools are at POST /mcp once the server is running.

Released under the MIT License.