# Protocol versions

> Which MCP revisions a FrontMCP server speaks on one endpoint, how it picks the revision for each request, and what changed in MCP 2026-07-28.

Source: https://frontmcp.dev/reference/server/protocol-versions

A FrontMCP 1.9.4 server speaks five MCP revisions, from 2024-11-05 to 2026-07-28, on the same endpoint. There's no setting to choose one: each request is served under the revision its client declares. The four older revisions share one design, a session that the client opens with `initialize`. 2026-07-28 drops it: every request stands on its own and carries its own version and capabilities, and the server never sends a request back to the client. This page says how FrontMCP tells the two apart, and lists what 2026-07-28 changed, with a link to the page that shows each change. The Playground's client speaks 2026-07-28, so the examples on this site do too; what older clients get was checked on a Node server.

```ts
// A 2026-07-28 request names its revision in a header and in the body:
//   MCP-Protocol-Version: 2026-07-28
//   { "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }

@FrontMcp({ info, apps, transport: { defaultProtocolVersion: "2026-07-28" } }) // for requests that name none
```

---

## Reference

### Supported revisions

| Revision | How a client starts | Sessions |
| --- | --- | --- |
| `2026-07-28` | With any request. `server/discover` tells it what the server offers, if it wants to know first. | None. |
| `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05` | With `initialize`, which answers with an `Mcp-Session-Id` the client sends from then on. | One per client. |

`server/discover` lists the five in `supportedVersions`, newest first. `initialize` answers with the revision the client asked for when it's one of the older four, and with `2025-11-25` when it's one FrontMCP doesn't know. An `initialize` that asks for `2026-07-28` in its params, without the header or `_meta` below, is an older client: it gets `2025-11-25` and a session. (`initialize` also accepts `2024-10-07`, which isn't listed.) The `initialize` answers were checked on a Node server.

### How a request's revision is chosen

FrontMCP serves a request as 2026-07-28 when any of these is true:

- its params have `_meta["io.modelcontextprotocol/protocolVersion"]`, which only 2026-07-28 clients send;
- its `MCP-Protocol-Version` header names a revision other than the older four: `2026-07-28`, or one FrontMCP doesn't know, which gets [`-32022`](#unsupported-protocol-version-);
- its method is `server/discover` or `subscriptions/listen`, which only 2026-07-28 has.

Otherwise the request belongs to an older revision: an `initialize`, a request with an `Mcp-Session-Id`, or one whose `MCP-Protocol-Version` header is one of the older four. A request that names no revision at all is served by [`transport.defaultProtocolVersion`](#transportdefaultprotocolversion).

A request served as 2026-07-28 because it said so must say so in both places, with the same value, and send the other [mirrored headers](https://frontmcp.dev/reference/sdk/create-fetch-handler#request-headers): a missing or different one gets HTTP `400` and `-32020`, before anything else runs.

### `transport.defaultProtocolVersion`

A request names no revision when it has no `MCP-Protocol-Version` header, no `_meta` version and no `Mcp-Session-Id`, and isn't an `initialize`: a bare JSON-RPC call. `transport.defaultProtocolVersion` decides how to serve it:

| Value | A request that names no revision |
| --- | --- |
| `"2026-07-28"` | Served as 2026-07-28. The mirrored headers aren't required, since the client never agreed to send them, but one it does send must match the body. |
| `"legacy"` | Served as an older revision. FrontMCP's Node server wants a session first, and answers `-32600` ``Session not initialized — send `initialize` first``. Behind `createFetchHandler()`, which keeps no sessions, the call is answered on its own, as an event stream. |

The default depends on how the server is served: `"legacy"` on FrontMCP's Node server, and `"2026-07-28"` behind [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler): Cloudflare Workers, Deno, Bun, and the Playground. Both were checked on Node; [Serving requests that name no revision](#serving-requests-that-name-no-revision) shows the second.

### What 2026-07-28 changed

| What changed | What FrontMCP does | Shown on |
| --- | --- | --- |
| **No sessions, no `initialize`.** Each request carries the client's version, `clientInfo` and `clientCapabilities` in `_meta`, and a capability counts only on the request that declares it. | `initialize` and `ping` get `404` and `-32601` ``Method not found: initialize was removed in protocol 2026-07-28``. An anonymous caller is a new caller on every request. | [Sessions](https://frontmcp.dev/reference/sdk/create-fetch-handler#sessions), [A server anyone can call](https://frontmcp.dev/learn/authenticating-clients#a-server-anyone-can-call) |
| **`server/discover`** tells a client the revisions, capabilities and instructions, as `initialize` did. | Lists the tasks extension, whether or not a tool runs as a task. | [What `server/discover` returns](https://frontmcp.dev/reference/server/apps#what-serverdiscover-returns) |
| **Mirrored headers.** `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-<name>` for arguments marked `x-mcp-header`, so a proxy can route without reading the body. | Checked against the body before anything else runs: `-32020` when one is missing or different. | [Request headers](https://frontmcp.dev/reference/sdk/create-fetch-handler#request-headers), [Header parameters](https://frontmcp.dev/reference/adapters/openapi#header-parameters) |
| **Every result says what it is**, in `resultType`: `complete`, `input_required` or `task`. | Every result also carries the server's `info` in `_meta["io.modelcontextprotocol/serverInfo"]`. Lists, reads and `server/discover` carry `ttlMs` and `cacheScope`, how long and how widely a client may cache them: 60 seconds for `tools/list`, `0` for `resources/read`. | [What `server/discover` returns](https://frontmcp.dev/reference/server/apps#what-serverdiscover-returns) |
| **No requests from the server.** Elicitation, sampling and roots become an `input_required` result, which the client answers by sending its request again. | The tool runs again from the top with the answer. `requestState` is signed with `VAULT_SECRET` (or `JWT_SECRET`), so every instance needs the same one. | [How an elicitation travels](https://frontmcp.dev/reference/sdk/elicit#how-an-elicitation-travels), [`this.sample()`](https://frontmcp.dev/reference/sdk/contexts#thissample-and-thislistroots), [High availability](https://frontmcp.dev/reference/deployment/high-availability) |
| **Notifications belong to a request.** A client asks for log messages and progress on the request itself, with `io.modelcontextprotocol/logLevel` and a `progressToken`; `logging/setLevel` is gone. | The answer is an event stream: the notifications, then the result. A request that doesn't ask gets no log messages. | [Asking for log messages](https://frontmcp.dev/reference/sdk/notify#asking-for-log-messages), [Asking for progress](https://frontmcp.dev/reference/sdk/progress#asking-for-progress) |
| **`subscriptions/listen`** replaces the `GET` stream and `resources/subscribe`. | A stream that starts with what the server will send. | [Listening for changes](#listening-for-changes), [Channels](https://frontmcp.dev/reference/sdk/channel#receiving-events-on-a-2026-07-28-client) |
| **Tasks are an extension**, `io.modelcontextprotocol/tasks`, without `tasks/result` and `tasks/list`, and with `tasks/update`. | Only signed-in callers can start a task. | [Background tasks](https://frontmcp.dev/reference/server/tasks) |
| **Trace context in `_meta`.** A client may send `traceparent` and `tracestate` in a request's `_meta`. | Copied to the result's `_meta`. Only the `traceparent` header joins the server's trace. | [Joining a trace](https://frontmcp.dev/reference/sdk/context#joining-a-trace) |
| **New error codes.** `-32020`, `-32021` and `-32022`; a missing resource is `-32602` (it was `-32002`). `GET` and `DELETE` on the endpoint get `405`. | As the protocol says. | [Error codes](https://frontmcp.dev/reference/errors#protocol-errors) |

#### Caveats

- **`frontmcp test` speaks an older revision.** Its client asks for `2025-06-18` and can't speak 2026-07-28, while the Playground's tests speak 2026-07-28: see [Testing](https://frontmcp.dev/reference/testing). A test of a missing resource, for one, gets `-32002` in one and `-32602` in the other.
- **A remote app speaks the older revision to its server** unless you set `transportOptions.protocolVersion`: see [Talking to a 2026-07-28 server](https://frontmcp.dev/reference/server/remote#talking-to-a-2026-07-28-server).

---

## Usage

### Asking which revisions a server speaks

A client that wants to know before it calls anything sends `server/discover`. The tests also send a revision FrontMCP doesn't know, and a request whose header and body disagree:

```ts versions.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

test("server/discover lists every revision the server speaks", async ({ mcp }) => {
  const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
  expect(result.supportedVersions).toEqual(["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]);
});

test("a revision FrontMCP doesn't know: 400 and -32022, with the ones it does", async () => {
  const server = await FrontMcpInstance.createFetchHandler(config);
  const response = await server(listTools("2099-01-01", "2099-01-01"));
  expect(response.status).toBe(400);
  expect((await response.json()).error).toEqual({
    code: -32022,
    message: "Unsupported protocol version: 2099-01-01",
    data: { supported: ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"], requested: "2099-01-01" },
  });
});

test("a header and a body that disagree: 400 and -32020", async () => {
  const server = await FrontMcpInstance.createFetchHandler(config);
  const response = await server(listTools("2026-07-28", "2025-11-25"));
  expect(response.status).toBe(400);
  expect((await response.json()).error).toEqual({
    code: -32020,
    message: "Header mismatch: mcp-protocol-version header value '2026-07-28' does not match body value '2025-11-25'",
  });
});

/** A tools/list that names one revision in its header and another in its body. */
function listTools(header: string, body: string) {
  return new Request("https://desk.example.com/", {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": header, "mcp-method": "tools/list" },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": body } } }),
  });
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { query, tickets: [] };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };

@FrontMcp(config)
export default class Server {}
```

An older client doesn't send `server/discover`. It asks for a revision in `initialize`, and reads the one it got from the answer's `protocolVersion`.

### Serving requests that name no revision

Some clients and scripts send plain JSON-RPC, with no header, no `_meta` and no session. Behind `createFetchHandler()`, as here, such a request is served as 2026-07-28, and the headers it doesn't send aren't required:

```ts bare.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

const call = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "search_tickets", arguments: { query: "log" } } };
const post = (headers: Record<string, string>) =>
  new Request("https://desk.example.com/", { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(call) });

test("a bare call is served as 2026-07-28", async () => {
  const server = await FrontMcpInstance.createFetchHandler(config);
  const response = await server(post({}));
  expect(response.status).toBe(200);
  expect((await response.json()).result).toMatchObject({ resultType: "complete", structuredContent: { query: "log", tickets: [] } });
});

test("a header it does send must still match the body", async () => {
  const server = await FrontMcpInstance.createFetchHandler(config);
  const response = await server(post({ "mcp-name": "close_ticket" }));
  expect(response.status).toBe(400);
  expect((await response.json()).error.message).toBe("Header mismatch: mcp-name header value 'close_ticket' does not match body value 'search_tickets'");
});
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { query, tickets: [] };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };

@FrontMcp(config)
export default class Server {}
```

FrontMCP's Node server answers the same bare call with `-32600` ``Session not initialized — send `initialize` first``, because it defaults to `"legacy"`. To serve such clients there, set the default:

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  transport: { defaultProtocolVersion: "2026-07-28" },
})
export default class Server {}
```

With it, the Node server answered the bare call as above, and `initialize` still opened a session for older clients. This was checked on a Node server.

### Listening for changes

A 2026-07-28 client has no session to subscribe with, so it sends `subscriptions/listen`, naming the notifications it wants, and keeps reading the answer: an event stream that stays open. Its first message, `notifications/subscriptions/acknowledged`, repeats the request's list without what the server can't send:

```ts listen.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

test("the stream starts by saying what it will carry", async () => {
  const server = await FrontMcpInstance.createFetchHandler(config);
  const wanted = { toolsListChanged: true, promptsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ["desk://queue"] };
  const response = await server(listen(wanted));
  expect(response.headers.get("content-type")).toContain("text/event-stream");

  const stream = response.body!.getReader();
  expect(await nextMessage(stream)).toEqual({
    jsonrpc: "2.0",
    method: "notifications/subscriptions/acknowledged",
    params: {
      // No promptsListChanged: the server has no prompts.
      notifications: { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ["desk://queue"] },
      _meta: { "io.modelcontextprotocol/subscriptionId": 7 },
    },
  });
  void stream.cancel(); // the client goes away
});

function listen(notifications: Record<string, unknown>) {
  return new Request("https://desk.example.com/", {
    method: "POST",
    headers: { "content-type": "application/json", accept: "text/event-stream", "mcp-protocol-version": "2026-07-28", "mcp-method": "subscriptions/listen" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 7,
      method: "subscriptions/listen",
      params: { notifications, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });
}

// The next message on the stream, or null when none comes within 200 ms.
async function nextMessage(stream: ReadableStreamDefaultReader<Uint8Array>) {
  const chunk = await Promise.race([stream.read(), new Promise<null>((resolve) => setTimeout(() => resolve(null), 200))]);
  if (!chunk || chunk.done) return null;
  const data = new TextDecoder().decode(chunk.value).split("\n").find((line) => line.startsWith("data: "));
  return data ? JSON.parse(data.slice("data: ".length)) : null;
}
```

```ts main.ts
import { App, FrontMcp, Resource, ResourceContext, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

@Resource({ name: "queue", uri: "desk://queue", mimeType: "application/json", description: "The open tickets, oldest first" })
class Queue extends ResourceContext {
  async execute(uri: string) {
    return { contents: [{ uri, text: JSON.stringify(["T-1", "T-2"]) }] };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket], resources: [Queue] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };

@FrontMcp(config)
export default class Server {}
```

Every message on the stream carries the request's `id` as `_meta["io.modelcontextprotocol/subscriptionId"]`. The stream carries a list change when the server's own list of tools, prompts or resources changes, and [channel events](https://frontmcp.dev/reference/sdk/channel#receiving-events-on-a-2026-07-28-client) to a client that asks for them. In 1.9.4 a tool's `this.notifyResourceUpdated(uri)` doesn't reach it: that goes only to [older clients' sessions](https://frontmcp.dev/reference/sdk/contexts#telling-clients-a-resource-changed). A resource's own `this.notifyUpdated()` does, as `notifications/resources/updated` to the streams that listed its URI. Both were checked on Node.

### Calling a 2026-07-28 server from code

FrontMCP has a 2026-07-28 client of its own, `McpStatelessClient` from `@frontmcp/sdk` (the MCP SDK's 1.x client, which FrontMCP builds on, stops at 2025-11-25: see [the comparison](https://frontmcp.dev/compare/official-typescript-sdk)). It sends the headers and `_meta` on every request, answers `input_required` results with its `handlers` and sends the request again, and polls [tasks](https://frontmcp.dev/reference/server/tasks#calling-a-task-tool-from-code) to the end:

```ts client.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance, McpStatelessClient } from "@frontmcp/sdk";
import { config } from "./main";

async function connect(options: Partial<ConstructorParameters<typeof McpStatelessClient>[0]> = {}) {
  const server = await FrontMcpInstance.createFetchHandler(config);
  return new McpStatelessClient({
    url: "https://desk.example.com/",
    // Hands each request to the server above, so this runs here. A real client leaves it out.
    fetchImpl: (url, init) => server(new Request(url, init)),
    ...options,
  });
}

test("discover() and listTools()", async () => {
  const desk = await connect();
  expect((await desk.discover()).supportedVersions).toContain("2026-07-28");
  expect((await desk.listTools()).map((tool) => tool.name)).toEqual(["close_ticket"]);
});

test("callTool() answers the tool's question, and resolves with the result", async () => {
  const asked: unknown[] = [];
  const desk = await connect({
    capabilities: { elicitation: { form: {} } },
    handlers: {
      onElicit: async (params) => {
        asked.push(params.message);
        return { action: "accept", content: { confirm: true } };
      },
    },
  });
  const result = await desk.callTool("close_ticket", { id: "T-1" });
  expect(asked).toEqual(["Close ticket T-1?"]);
  expect(result.structuredContent).toEqual({ id: "T-1", status: "closed" });
});

test("without the elicitation capability, the call fails with -32021", async () => {
  const desk = await connect();
  await expect(desk.callTool("close_ticket", { id: "T-1" })).rejects.toMatchObject({
    name: "McpStatelessError",
    code: -32021,
    message: "This request requires the `elicitation` client capability",
  });
});
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { id, status: answer.content?.confirm ? "closed" : "open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], elicitation: { enabled: true } };

@FrontMcp(config)
export default class Server {}
```

| Option | Description |
| --- | --- |
| `url` | **Required.** The server's MCP endpoint. |
| `capabilities` | Sent on every request, like `{ elicitation: { form: {} } }` or `{ extensions: { "io.modelcontextprotocol/tasks": {} } }`. Defaults to none. |
| `headers` | Sent on every request, like `Authorization`. |
| `clientInfo` | `{ name, version }`. Defaults to `frontmcp-20260728-client`. |
| `handlers` | `onElicit`, `onSample` and `onListRoots`: how to answer what a tool asks. A request with no handler fails with `Server requested elicitation but no handler is configured`. |
| `logLevel`, `onNotification` | `logLevel` asks for log messages on every request; `onNotification` receives each notification that arrives before a result. |
| `maxInputRounds` | How many times a tool may ask before the call fails with `Server kept requesting input after 8 rounds`. Defaults to 8. |
| `requestTimeoutMs` | How long to wait for an answer to one request. |
| `fetchImpl` | The `fetch` to send with. Defaults to the global one. |

Besides `discover()`, `listTools()` and `callTool()`, it has `readResource()`, `listResources()`, `listResourceTemplates()`, `listPrompts()`, `getPrompt()`, `cancelTask(taskId)`, `listen(notifications, onNotification)` for [`subscriptions/listen`](#listening-for-changes), and `request(method, params)`. Errors are `McpStatelessError`s, with the JSON-RPC `code`. A tool that fails returns a result with `isError: true`, as with any client.

### Serving clients on older revisions

Nothing has to be set: an older client opens a session with `initialize` on the same endpoint, and every request it sends with its `Mcp-Session-Id` is served as before. Which older transports it may use, Streamable HTTP or the old SSE one, is [`transport.protocol`](https://frontmcp.dev/reference/sdk/frontmcp#transport); sessions need `MCP_SESSION_SECRET` in production. A few things differ for these clients, each on its page:

| Feature | Before 2026-07-28 |
| --- | --- |
| Asking the user | FrontMCP sends `elicitation/create` to the client, and `this.elicit()` waits for the answer. A client that can't show forms gets a `sendElicitationResult` tool: see [`this.elicit`](https://frontmcp.dev/reference/sdk/elicit#the-model-is-asked-to-call-sendelicitationresult). |
| Log messages | The client sets a level with `logging/setLevel`: see [`this.notify`](https://frontmcp.dev/reference/sdk/notify). |
| Resource changes | `resources/subscribe`, and `notifications/resources/updated` to the subscribed sessions: see [Telling clients a resource changed](https://frontmcp.dev/reference/sdk/contexts#telling-clients-a-resource-changed). |
| Tasks | `task` in the call, `tasks/result`, `tasks/list`, and status notifications: see [Clients on older protocol versions](https://frontmcp.dev/reference/server/tasks#clients-on-older-protocol-versions). |
| Anonymous callers | One caller for the whole session, so a task it starts can be read back. |
| Behind `createFetchHandler()` | No session is kept: see [Serving clients on older protocol versions](https://frontmcp.dev/reference/sdk/create-fetch-handler#serving-clients-on-older-protocol-versions). |

---

## Troubleshooting

### `Unsupported protocol version: …`

HTTP `400` with JSON-RPC error `-32022`. The request's `MCP-Protocol-Version` header names a revision FrontMCP 1.9.4 doesn't speak; `data.supported` lists the ones it does. The first example's [second test](#asking-which-revisions-a-server-speaks) gets it. Send one of those, or upgrade the server when a newer one is what the client needs.

### `Header mismatch: mcp-protocol-version header value '…' does not match body value '…'`

HTTP `400` with `-32020`. The header and `_meta["io.modelcontextprotocol/protocolVersion"]` name different revisions. They must be the same. Other mismatches, and missing headers, are in [`createFetchHandler()`'s troubleshooting](https://frontmcp.dev/reference/sdk/create-fetch-handler#header-mismatch-the-mcp-protocol-version-header-is-required).

### `Method not found: initialize was removed in protocol 2026-07-28`

HTTP `404` with `-32601`. The client sent `initialize` with a 2026-07-28 header or `_meta`, so FrontMCP served it as 2026-07-28, which has no `initialize`. A 2026-07-28 client starts with any request, or `server/discover`; an older client must not send the 2026-07-28 header. The same goes for `ping`, `logging/setLevel`, `resources/subscribe`, `tasks/result` and `tasks/list`.

### ``Session not initialized — send `initialize` first``

JSON-RPC error `-32600` from FrontMCP's Node server. The request named no revision and carried no session, and the server defaults to `"legacy"`. Have the client declare 2026-07-28 with the header and `_meta`, or set [`transport: { defaultProtocolVersion: "2026-07-28" }`](#serving-requests-that-name-no-revision).
