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 (
/mcpby 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.
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):
| Option | Default | Description |
|---|---|---|
port | 3000 | 0 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.keyGenerator | socket address | X-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.
import { toFetchHandler } from "@kervan/transport"
export default toFetchHandler(app, { allowedHosts: ["mcp.example.com"] })| Option | Default | Description |
|---|---|---|
path | "/mcp" | |
allowedHosts | localhost only | Accepted Host header names (DNS rebinding protection, 403 otherwise) |
allowedOrigins | localhost only | Accepted Origin names; requests without Origin pass |
maxRequestBodySize | 4 MiB | 413 above it |
responseMode | "auto" | "json" drops mid-call notifications; "sse" always streams |
legacy | "stateless" | "reject" serves 2026-07-28 clients only |
rejectBatches | false | Answer 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 registry | Runs 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:
node packages/cli/bin/kervan.js run examples/spec/kervan.yaml --http --host 0.0.0.0 --allowed-host mcp.example.comWithout --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.