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

FieldTypeAvailableNotes
era"modern" | "legacy"bothWhich path served this request
protocolVersionstringbothNegotiated version (legacy: header; modern: _meta)
requestIdstring | numberbothJSON-RPC id
signalAbortSignalbothCancellation for this request
progressTokenstring | numberwhen the client sent oneOpts the request into a stream
metaRecord<string, unknown>when the request sent itThe raw params._meta, for extension keys — untrusted, validate what you read
optionsResolvedOptionsbothThe resolved handler options
sessionIdstringlegacy, sessions onCurrent Mcp-Session-Id
authAuthIdentityboth, auth onWhat the transport auth check admitted; undefined with auth off
principalstringboth, auth onOpaque key for the admitted credential — see below; undefined with auth off
lastEventIdstringlegacy GETFrom Last-Event-ID or ?lastEventId=
clientInfoImplementationmodern_meta["io.modelcontextprotocol/clientInfo"]
clientCapabilitiesClientCapabilitiesmodernDeclared per request
logLevelLogLevelboth, if requestedMinimum level the client wants (modern: _meta; legacy: logging/setLevel)
trace{ traceparent?, tracestate?, baggage? }modernW3C trace context propagated through _meta
inputResponsesInputResponsesmodern, on an MRTR retryAnswers keyed by request name
requestStatestringmodern, on an MRTR retryAttacker-controlled — verify it
transportSignalAbortSignalboth, streamingFires when the response stream closes; linked into signal
cacheHintsCacheHintsmodernHints 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

MethodPurpose
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:

EraOpt-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.

Read more in Request-scoped streaming.

#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.

Read more in Multi round-trip requests.

h3-mcp  MCP servers, built on H3.