Protocol versions

MCP 2026-07-28

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.

// 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

RevisionHow a client startsSessions
2026-07-28With 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-05With 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;
  • 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.

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

ValueA 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(): Cloudflare Workers, Deno, Bun, and the Playground. Both were checked on Node; Serving requests that name no revision shows the second.

What 2026-07-28 changed

What changedWhat FrontMCP doesShown 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, 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
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, 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
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, this.sample(), 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, 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, Channels
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
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
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

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

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:

Open
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 } } }),
  });
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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'");
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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:

Open
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;
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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. 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). It sends the headers and _meta on every request, answers input_required results with its handlers and sends the request again, and polls tasks to the end:

Open
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",
  });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

OptionDescription
urlRequired. The server's MCP endpoint.
capabilitiesSent on every request, like { elicitation: { form: {} } } or { extensions: { "io.modelcontextprotocol/tasks": {} } }. Defaults to none.
headersSent on every request, like Authorization.
clientInfo{ name, version }. Defaults to frontmcp-20260728-client.
handlersonElicit, 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, onNotificationlogLevel asks for log messages on every request; onNotification receives each notification that arrives before a result.
maxInputRoundsHow many times a tool may ask before the call fails with Server kept requesting input after 8 rounds. Defaults to 8.
requestTimeoutMsHow long to wait for an answer to one request.
fetchImplThe 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, and request(method, params). Errors are McpStatelessErrors, 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; sessions need MCP_SESSION_SECRET in production. A few things differ for these clients, each on its page:

FeatureBefore 2026-07-28
Asking the userFrontMCP 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.
Log messagesThe client sets a level with logging/setLevel: see this.notify.
Resource changesresources/subscribe, and notifications/resources/updated to the subscribed sessions: see Telling clients a resource changed.
Taskstask in the call, tasks/result, tasks/list, and status notifications: see Clients on older protocol versions.
Anonymous callersOne 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.

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

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" }.