HTTP Transport
HTTP Transport at the Askr server boundary, with validated input and explicit failure states.
Example
Create one MCP server, register typed primitives on it, and let the selected transport supply auth, cancellation, sessions, and progress.
import { schema } from '@askrjs/schema';
import { createMcpServer } from '@askrjs/server/mcp';
export const mcp = createMcpServer({
name: 'project-tools',
version: '1.0.0',
}).tool(
'lookup-project',
{ input: schema.object({ id: schema.uuid() }) },
async (context, { id }) => ({
content: [{ type: 'text', text: await lookup(id, context.signal) }],
})
);HTTP requests
registerMcpRoutes(router, path, mcp, options) mounts POST, GET, and DELETE handlers for path on an existing Router. POST requires content-type: application/json, an accept header that satisfies both application/json and text/event-stream (not either/or — a client that only sends Accept: application/json gets rejected too), and a body under options.maxRequestBytes (1 MiB by default) — violations return 415, 406, and 413 respectively before the body is even parsed. Origin and host checks run first via options.allowedOrigins / options.allowedHosts, and requests with an unrecognized mcp-protocol-version header are rejected with 400 before reaching the MCP server.
Responses
A successful POST that produces a result returns 200 with the JSON-RPC response body; a notification or request that yields no result (like notifications/initialized) returns a bare 202 with no body. When options.stateful is true, the first initialize call gets a fresh mcp-session-id header (a crypto.randomUUID()), which the client must echo on every subsequent request; a missing or unknown session ID on a stateful deployment produces 400 or 404. Malformed JSON in the request body returns a JSON-RPC -32700 Parse error at HTTP 400, and JSON-RPC batch arrays are rejected outright with -32600.
Protected resource metadata
Pass resource (and optionally authorizationServers) in the options and the adapter automatically serves GET /.well-known/oauth-protected-resource, backed by protectedResourceMetadata(resource, authorizationServers). The returned object is frozen and includes resource, authorization_servers, and a fixed bearer_methods_supported: ["header"], matching the OAuth protected-resource metadata clients use for discovery. You can also call protectedResourceMetadata directly if you need to serve that document from a route the adapter doesn't own.
Deployment
Statefulness is opt-in: pass stateful: true to get session IDs, SSE push, and the DELETE-to-terminate flow; omit it and every POST is handled independently with methodNotAllowed returned for GET and DELETE. By default sessions live in an in-memory store that's bounded, not unlimited — a session expires after sessionTtlMs (30 minutes by default) and the store caps out at maxSessions (1,000 by default), returning 503 once that capacity is reached — and it only works for a single process either way, so supply your own sessionStore (matching the create/has/delete shape) if you're running more than one instance behind a load balancer. Set allowedOrigins and allowedHosts for any endpoint reachable from a browser to guard against DNS-rebinding-style requests, and tune heartbeatInterval (default 30 seconds) if intermediaries close idle connections faster than that.