h3-mcp logoh3-mcp

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 _meta per 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 requestEraBehavior
params._meta["io.modelcontextprotocol/protocolVersion"]modernstateless 2026-07-28 path
method: "initialize"legacyhandshake + optional session
Mcp-Session-Id header, no _meta versionlegacyexisting session path
GET / DELETElegacymodern-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:

ImportServesMinGzip
h3-mcpboth (era picks)~34kb~11kb
h3-mcp/modern2026-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

Read more in Full method reference.

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

Read more in Request context.
h3-mcp logo

h3-mcp  MCP servers, built on H3.