Request Context
event.context.mcp — everything about the request in flight.
Every handler receives the H3 event, and event.context.mcp carries the MCP state for that request. The property is typed via h3's H3EventContext augmentation, and it is optional (mcp?) because the same event type is used outside MCP routes — use event.context.mcp! inside a handler, or optional calls (mcp.progress?.()) if you prefer no assertions.
defineTool({
name: "context",
handler: (event) => {
const mcp = event.context.mcp!;
return {
content: [
{ type: "text", text: `${mcp.era} · ${mcp.protocolVersion} · ${mcp.clientInfo?.name}` },
],
};
},
});#Fields
| Field | Type | Available | Notes |
|---|---|---|---|
era | "modern" | "legacy" | both | Which path served this request |
protocolVersion | string | both | Negotiated version (legacy: header; modern: _meta) |
requestId | string | number | both | JSON-RPC id |
signal | AbortSignal | both | Cancellation for this request |
progressToken | string | number | when the client sent one | Opts the request into a stream |
meta | Record<string, unknown> | when the request sent it | The raw params._meta, for extension keys — untrusted, validate what you read |
options | ResolvedOptions | both | The resolved handler options |
sessionId | string | legacy, sessions on | Current Mcp-Session-Id |
auth | AuthIdentity | both, auth on | What the transport auth check admitted; undefined with auth off |
principal | string | both, auth on | Opaque key for the admitted credential — see below; undefined with auth off |
lastEventId | string | legacy GET | From Last-Event-ID or ?lastEventId= |
clientInfo | Implementation | modern | _meta["io.modelcontextprotocol/clientInfo"] |
clientCapabilities | ClientCapabilities | modern | Declared per request |
logLevel | LogLevel | both, if requested | Minimum level the client wants (modern: _meta; legacy: logging/setLevel) |
trace | { traceparent?, tracestate?, baggage? } | modern | W3C trace context propagated through _meta |
inputResponses | InputResponses | modern, on an MRTR retry | Answers keyed by request name |
requestState | string | modern, on an MRTR retry | Attacker-controlled — verify it |
transportSignal | AbortSignal | both, streaming | Fires when the response stream closes; linked into signal |
cacheHints | CacheHints | modern | Hints recorded for the result decorator |
principal is auth.subject when the validator reported one, otherwise a SHA-256 hex digest of the presented token — never the token itself, and not stable across a rotation of a subject-less token. It is what scopes a legacy notifications/cancelled to its sender's own requests when there is no session to scope it by, and what a legacy session is bound to at initialize.
#Methods
| Method | Purpose |
|---|---|
progress(progress, total?, msg?) | Emit notifications/progress for this request's progressToken |
log(level, data, logger?) | Emit notifications/message, gated on the client's requested level |
notify(method, params?) | Emit any notification on this request's stream |
requireClientCapability(...names) | Throw -32021 unless every capability was declared |
All four are optional properties, and the first three no-op when the request has no stream. Both eras have one; they differ only in how the client opts in:
| Era | Opt-in |
|---|---|
| modern | _meta.progressToken and/or _meta["io.modelcontextprotocol/logLevel"] on the request |
| legacy | _meta.progressToken on the request, and/or an earlier logging/setLevel — plus Accept: text/event-stream |
So the same handler streams on both eras, and falls back to a plain JSON response on both when the client asked for nothing.
Important
A server that emits notifications/message MUST declare the logging capability, so declaring it is also what enables it. log() is the same call on both eras, and nothing can detect statically that a handler uses it — so if you call it, set logging: true, which declares the capability on both (initialize on legacy, server/discover on modern) and lets a level come into force. onLogLevelSet implies it. Without either, log() is a permanent no-op: logging/setLevel is not served and the modern _meta log level is ignored.
#Era Differences
Nothing in the context is a lie about the other era — fields simply stay undefined where they do not apply. That makes era-agnostic handlers easy:
// Works on both eras
const mcp = event.context.mcp!;
if (mcp.signal?.aborted) return;
mcp.progress?.(50, 100); // delivered on either era if the client opted in, otherwise dropped
// Era-specific
if (mcp.era === "legacy" && mcp.sessionId) {
await touchSession(mcp.sessionId);
}Important
Two fields come from the client and must never be trusted: requestState (validated only as a bounded string) and anything inside inputResponses. Verify them exactly as you would a request body.