Transports and authentication

The same app serves stdio and Streamable HTTP, on Node or as a standard fetch handler (tested on Node), for clients of both protocol eras.

Where this fits: Run and operate. How clients reach the tools you built in YAML or TypeScript.

On this page

Which transport

  • stdio: the client starts your server as a process and talks over stdin and stdout. This is what desktop clients and Claude Code use for local servers. Nothing listens on the network.
  • Streamable HTTP: your server listens on a URL (/mcp by default). Use it for remote servers, several clients, or a server behind a proxy. Kervan serves it statelessly: no sessions, a fresh SDK server per request.

kervan run spec.yaml serves stdio; kervan run spec.yaml --http serves HTTP on 127.0.0.1:3000. In code, serve(app) reads --http/--stdio, then KERVAN_TRANSPORT, and defaults to stdio.

ts
import { serve, serveHttp, serveStdio } from "@kervan/transport/node"

await serve(app) // --http / --stdio flag, else KERVAN_TRANSPORT, else stdio; closes on SIGINT/SIGTERM

serveStdio(app) // options: { registry, legacy }
const server = await serveHttp(app, { port: 3000 }) // server.url, server.close()

serveHttp options (in addition to the fetch handler options below):

OptionDefaultDescription
port30000 picks a free port. serve also reads --port= and PORT.
host"127.0.0.1"Interface to bind. serve also reads --host= and HOST. Anything but loopback needs allowedHosts: without it serveHttp refuses to start, and serve says why in one line and exits 1.
rateLimit{ windowMs: 60000, max: 300 }Per client, in memory, 429 with Retry-After. false disables it.
rateLimit.keyGeneratorsocket addressX-Forwarded-For is not trusted. Behind a proxy, derive the key yourself.

The rate limiter only exists in serveHttp. With toFetchHandler, use your platform’s rate limiting.

A fetch handler

toFetchHandler turns the app into a standard Fetch API handler (Request in, Response out). It is tested on Node only; Cloudflare Workers, Deno and Bun are not tested.

ts
import { toFetchHandler } from "@kervan/transport"

export default toFetchHandler(app, { allowedHosts: ["mcp.example.com"] })
OptionDefaultDescription
path"/mcp"
allowedHostslocalhost onlyAccepted Host header names (DNS rebinding protection, 403 otherwise)
allowedOriginslocalhost onlyAccepted Origin names; requests without Origin pass
maxRequestBodySize4 MiB413 above it
responseMode"auto""json" drops mid-call notifications; "sse" always streams
legacy"stateless""reject" serves 2026-07-28 clients only
rejectBatchesfalseAnswer JSON-RPC batches (protocol 2025-03-26 only) with 400. Turn it on when you limit or bill per request, because a batch carries many calls in one request.
authenticate(request, { params })Return AuthInfo (reaches tools as ctx.auth), a Response to reject, or undefined
resolveServer(request, { auth, params })app’s registryRuns after authenticate. Return a ToolRegistry, null (404) or FORBIDDEN (403). Derive the tenant from the verified auth; return the same registry object for the same tenant. params holds the route parameters of path (e.g. /s/:serverId/mcp); check them against auth. A registry’s optional serverInfo sets the name, version and instructions clients see.

The handler is stateless: a fresh SDK server handles each request, and no sessions are created. 2025-era session operations (GET, DELETE) get 405.

Each registry gets its own SDK handler (handler.handlerFor(registry)), so change notifications stay within a tenant. Registry changes are pushed to 2026-07-28 clients on their subscriptions/listen stream. 2025-era HTTP clients cannot be pushed to (there is no stream in stateless legacy serving); they see changes on their next tools/list. On stdio, both eras get pushed notifications.

Host and Origin checks

HTTP servers check the Host header against allowedHosts and, when a request has one, the Origin header against allowedOrigins. Both default to localhost only. This stops DNS rebinding: a web page cannot reach your local server through a name it controls. Off localhost, name the hosts clients use:

sh
node packages/cli/bin/kervan.js run examples/spec/kervan.yaml --http --host 0.0.0.0 --allowed-host mcp.example.com

Without --allowed-host, kervan run refuses to listen beyond loopback, and so does serveHttp without allowedHosts.

Authentication

There is no built-in user store: authenticate(request, { params }) is yours. Return an AuthInfo (tools read it as ctx.auth), a Response to refuse the request, or undefined. resolveServer(request, { auth, params }) then picks the tool set for that caller, which is how one server serves several tenants: derive the tenant from the verified auth, never from a header the client chose, and return the same registry object for the same tenant.

Kervan does not implement OAuth 2.1 with protected resource metadata, which the MCP authorization specification describes for remote servers; put an authenticating proxy or your own authenticate in front.