Protocol Eras
One endpoint, two incompatible revisions of the spec.
MCP 2026-07-28 is a breaking revision. It removes the initialize handshake, protocol-level sessions, the standalone GET/SSE endpoint, ping, and logging/setLevel, and carries version, identity, and capabilities in per-request _meta instead. It is not wire-compatible with 2025-11-25 in either direction.
Following the spec's own terminology, h3-mcp calls these:
- Modern — revisions carrying
_metaper request:2026-07-28. - Legacy — revisions that negotiate through
initialize:2025-11-25,2025-06-18,2025-03-26. - Dual-era — an implementation serving both. This is the default.
#Dispatch
Because the two eras cannot be told apart by URL, the handler decides per request:
| Signal on the incoming request | Era | Behavior |
|---|---|---|
params._meta["io.modelcontextprotocol/protocolVersion"] | modern | stateless 2026-07-28 path |
method: "initialize" | legacy | handshake + optional session |
Mcp-Session-Id header, no _meta version | legacy | existing session path |
GET / DELETE | legacy | modern-only servers answer 405 |
Both eras serve the same tools, resources, resource templates, and prompts — you write a definition once and every client can reach it.
#Narrowing at Runtime
Set era to switch off the surface you don't want:
defineMcpHandler({
name: "my-server",
version: "1.0.0",
era: "dual", // "dual" (default) | "modern" | "legacy"
tools: [/* ... */],
});With era: "modern", the legacy surface is gone: GET/DELETE return 405, Mcp-Session-Id and Last-Event-ID are ignored, and a legacy initialize is answered with -32022 listing the versions the server does support.
With era: "legacy", a modern request is rejected the same way — -32022 naming the legacy versions — because legacy clients have no fall-forward mechanism of their own.
#Entrypoints
era narrows behavior, but both implementations are still bundled. To also drop the era you don't serve, import it directly:
| Import | Serves | Min | Gzip |
|---|---|---|---|
h3-mcp | both (era picks) | ~34kb | ~11kb |
h3-mcp/modern | 2026-07-28 only | ~28kb | ~9kb |
h3-mcp/legacy | ≤2025-11-25 only | ~19kb | ~6kb |
// Stateless 2026-07-28 only: no sessions, no GET/SSE, no initialize handshake.
import { defineMcpHandler } from "h3-mcp/modern";
// initialize-based revisions only: no per-request _meta, no server/discover.
import { defineMcpHandler } from "h3-mcp/legacy";Each subpath takes the same options and behaves exactly like the default entrypoint with era set — except the other era's implementation is absent rather than merely disabled. The era option is therefore ignored on the subpaths, and each entrypoint re-exports the full public type surface, so you never need to import from two paths at once.
Tip
Serving one era from a subpath is the right default for a new server that only needs to reach modern clients, or for an existing deployment frozen on the legacy revisions. Reach for the dual entrypoint when your clients are mixed — the extra ~15kb buys you compatibility with both.
#Which Methods Exist Where
Shared by both eras:
tools/list · tools/call · resources/list · resources/read · resources/templates/list · prompts/list · prompts/get · completion/complete
Modern only:
server/discover · subscriptions/listen
Legacy only:
initialize · notifications/initialized · ping · notifications/cancelled · notifications/progress · resources/subscribe · resources/unsubscribe · logging/setLevel
#Version Constants
Each era entrypoint exports the version it serves, so you never have to hardcode a date string:
import { MCP_PROTOCOL_VERSION } from "h3-mcp/modern"; // "2026-07-28"
import { MCP_LEGACY_PROTOCOL_VERSION } from "h3-mcp/legacy"; // "2025-11-25", reported by `initialize`The version negotiated for the request in flight is always on the request context as event.context.mcp.protocolVersion, whichever entrypoint you mounted.