Sessions
Sessions 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) }],
})
);Session identity
In stateful HTTP mode, a session is identified purely by the `mcp-session-id` header: the server mints one with `crypto.randomUUID()` the moment it sees an `initialize` call with no existing session ID, and the client is expected to send that same header on every subsequent request. Stdio sessions work differently — `connectMcpStdio` generates a single random session ID per process connection and uses it for every message on that stream, since there's only ever one "client" per stdio pipe.
State ownership
Two separate stores back a session in stateful HTTP mode: the `McpSessionStore` (`create`/`has`/`delete`, backed by a TTL- and capacity-bounded in-memory `Map` by default — see `sessionTtlMs`/`maxSessions`) tracks which session IDs are currently valid, while the MCP server itself keeps a parallel map of initialized session state — negotiated `client` info, `clientCapabilities`, and `protocolRevision` — keyed by that same ID. The two are meant to stay in sync: a request is only dispatched once `sessions.has(id)` is true at the HTTP layer and the server-side map has an entry for that ID, and `terminateSession` clears the server-side entry and any SSE listeners for that ID.
Expiry
The default in-memory session store does expire sessions on its own: `sessionTtlMs` (30 minutes by default) ages an ID out automatically, and `maxSessions` (1,000 by default) bounds how many can be live at once, returning `503` on a new session once that cap is hit. Once a session is gone — aged out, capacity-evicted, or explicitly deleted via `DELETE path` — the adapter treats it the same way: `404 MCP session not found` on the next request that references it. A custom `sessionStore` can implement its own eviction policy the same way, by having `has(id)` return `false` once you consider a session gone.
Horizontal scaling
The default in-memory session store and the server's own initialized-session map are both process-local, so a stateful deployment behind multiple instances needs sticky routing on `mcp-session-id`, a shared `sessionStore` implementation, or both. Because SSE channels (the `Map` of open `send` functions) are also held in the process that accepted the GET request, notifications for a given session can only be pushed by the instance holding that connection — routing by session ID isn't optional if you scale out and want push to keep working, not just request/response.