
# 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#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 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:

```ts
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                           | Gzip    |
| --------------- | -------------------------------- | ------- |
| `h3-mcp`        | both (`era` picks)               | ~17kb   |
| `h3-mcp/modern` | `2026-07-28` only                | ~14.5kb |
| `h3-mcp/legacy` | `≤2025-11-25` only               | ~11kb   |
| `h3-mcp/tasks`  | the tasks extension (plugin)     | ~3.5kb  |
| `h3-mcp/ui`     | the MCP Apps extension (plugin)  | ~1kb    |
| `h3-mcp/skills` | the Skills extension (plugin)    | ~6kb    |
| `h3-mcp/oauth`  | OAuth metadata + token verifiers | ~5kb    |

Rounded, and deliberately so — these are here for the shape of the trade, not for a byte count. The
exact budgets live in `test/bench/bundle.test.ts` and are enforced on every run.

The three extension entrypoints are additive: each is in your bundle only if you pass its plugin to
`defineMcpHandler`. They are also independent — adding [MCP Apps](/protocols/modern/apps) or
[Skills](/protocols/modern/skills) to a server that already serves [tasks](/protocols/modern/tasks)
costs the same as adding it to one that does not.

`h3-mcp/oauth` is also optional, but it is not a plugin. Its token verifiers are `auth.validate`
callbacks. If you use another verifier, they are not included in your bundle.
See [OAuth](/security/auth#oauth).

```ts
// 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 ~3kb gzip 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{to="/api/methods" title="Full method reference"}

## Version Constants

Each era entrypoint exports the version it serves, so you never have to hardcode a date string:

```ts
import { PROTOCOL_VERSION } from "h3-mcp/modern"; // "2026-07-28"
import { 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{to="/api/request-context" title="Request context"}

::card-group

::card
---

title: Modern (2026-07-28)
icon: ph:sparkle-duotone
to: /protocols/modern/discovery
---

Discovery, caching, request headers, subscriptions, streaming, MRTR, extensions.
::

::card
---

title: Legacy (≤2025-11-25)
icon: ph:clock-counter-clockwise-duotone
to: /protocols/legacy/handshake
---

The `initialize` handshake, sessions, GET/SSE, resource subscriptions, logging.
::

::
