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.
// 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-Versionheader names a revision other than the older four:2026-07-28, or one FrontMCP doesn't know, which gets-32022; - its method is
server/discoverorsubscriptions/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:
| 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(): 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 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, 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 testspeaks an older revision. Its client asks for2025-06-18and 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-32002in one and-32602in 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:
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:
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:
@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:
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:
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.
| 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, 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:
| 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. |
| Log messages | The client sets a level with logging/setLevel: see this.notify. |
| Resource changes | resources/subscribe, and notifications/resources/updated to the subscribed sessions: see Telling clients a resource changed. |
| Tasks | task in the call, tasks/result, tasks/list, and status notifications: see 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. |
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" }.