Browse the blog

MCP 2026-07-28 Dropped the Handshake: Where the TypeScript MCP Frameworks Stand

  • mcp
  • typescript
  • protocol
  • stateless
  • frontmcp

MCP's 2026-07-28 revision removed the initialize handshake and the Mcp-Session-Id header. Every request now carries its own protocol version and client capabilities, and a server no longer sends requests back to the client in the middle of a call. Any instance can answer any request, which suits serverless and edge hosting. But anything a server kept in its session has to live somewhere else now, and a server that speaks only one era of the protocol can't talk to clients of the other.

This post covers what changed, what it means for a server, where each TypeScript MCP framework stood on 2026-10-20, and how to move a server over. I maintain FrontMCP, one of the frameworks below, so read that section knowing who wrote it. Every fact about another library links to its own documentation, at the version checked.

What changed in the protocol

The 2026-07-28 changelog lists the changes. The ones that shape a server:

  • No sessions, no handshake. initialize, notifications/initialized and ping are gone, and so is Mcp-Session-Id. Each request carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in its _meta, and a capability counts only on the request that declares it.
  • server/discover tells a client which revisions, capabilities and instructions a server has, as initialize did. Servers must implement it, and clients may call it first.
  • No requests from the server. Elicitation, sampling and roots used to be requests the server sent mid-call. Now a server answers with an input_required result listing what it needs, and the client sends the original request again with the answers. Every result says which kind it is, in resultType.
  • Notifications belong to a request. A client asks for progress and log messages on the request itself; logging/setLevel is gone. Change notifications, like a new tool list, come from one long-lived subscriptions/listen stream, which replaces the GET stream and resources/subscribe.
  • Mirrored headers. Mcp-Method and Mcp-Name repeat what's in the body, so a proxy can route a request without reading it.
  • Tasks moved to an extension, io.modelcontextprotocol/tasks. Roots, sampling and logging are deprecated.
  • New error codes: -32020 for a header that doesn't match the body, -32021 for a missing client capability, -32022 for an unsupported version. A missing resource is now -32602.

What it means for a server

State moves out of the session. A session was a convenient place for per-conversation state: a cart, a draft, an approval the user gave "for this conversation". The changelog's answer is "explicit, server-minted handles passed as ordinary tool arguments": a tool returns an id, and the model passes it to the next call. State that belongs to a person can be keyed by the signed-in user instead. An anonymous caller is a new caller on every request.

Any instance can answer any request. Without sessions, a load balancer needs no sticky routing, and a serverless or edge function needs no session store. The exception is the input_required round trip: the server hands the client a requestState to send back with the answer, and another instance may receive it. If your server signs or encrypts that state, as it should, every instance needs the same key.

A tool may run more than once. When a tool asks the user something, it stops, returns input_required, and runs again from the top when the answer arrives. Everything it did before asking happens twice, so a tool that asks should ask before it changes anything.

Both eras will be around for a while. Clients move to a new revision at their own pace, and a server that speaks only 2026-07-28 refuses a client that starts with initialize. A server that speaks only the older revisions can't answer a client that skips it.

Where the TypeScript frameworks stand

Checked on 2026-10-20, each against its own documentation, and for most by running a server and calling it with each revision:

Framework, version checkedMCP revisions2026-07-28 over HTTP2026-07-28 over stdioSessions for older clients, over HTTP
Official TypeScript SDK 2.3.12024-11-05 to 2026-07-28; 2026-07-28 is opt-in (Protocol versions)Yes, from createMcpHandler()Yes, from serveStdio()None from createMcpHandler(); one from a transport wired by hand (HTTP)
FastMCP (punkpeye) 4.23.42024-11-05 to 2025-11-25; the README says it doesn't support 2026-07-28 (README)No: 400No: -32601Yes; none in stateless mode (Stateless mode)
ViteMCP 1.9.52024-11-05 to 2026-07-28 in our run; the README says 2026-07-28 only (README)YesYesNone, in our run
FastMCP TypeScript (Prefect) 1.7.32024-11-05 to 2026-07-28 (Protocol eras)YesYesYes, in the instance's memory; none in stateless mode (Stateless mode)
mcp-use 2.8.22024-11-05 to 2026-07-28 (Server overview)YesNo stdio serverNone (Migration)
xmcp 1.7.12024-11-05 to 2026-07-28 (Transports)YesYesNone (Transports)
mcp-framework 0.2.222024-11-05 to 2025-11-25 (Introduction)No: 400No: -32601Yes, in the process (issue #187)
FrontMCP 1.10.52024-11-05 to 2026-07-28 (Protocol versions)YesYes, since 1.10.5Yes, which Redis can share between instances (Redis)

"Yes" and "No" are what each server answered: a server/discover and a 2026-07-28 tools/call, with no initialize. Compare has the method, and a page for each framework next to FrontMCP.

Serving both eras with FrontMCP

A FrontMCP server serves all five revisions, from 2024-11-05 to 2026-07-28, on one HTTP endpoint, and there's no setting to choose. Each request is served under the revision it declares:

  • A request with the version in its _meta, a 2026-07-28 MCP-Protocol-Version header, or a method only 2026-07-28 has (server/discover, subscriptions/listen) is served as 2026-07-28.
  • An initialize, or a request with an Mcp-Session-Id, gets the older revision and a session.
  • A bare JSON-RPC call that names no revision goes by transport.defaultProtocolVersion. That defaults to the older revisions on FrontMCP's Node server, and to 2026-07-28 behind createFetchHandler() on Cloudflare Workers, Deno and Bun.

Since 1.10.5, a stdio connection serves both eras the same way, message by message. An initialize makes the connection a session for good; until then, a message that names no revision goes by transport.defaultProtocolVersion, which defaults to the older revisions over stdio.

Features that used to rely on a session follow the user when there's none. Under 2026-07-28, the Approval plugin keys an approval given "for this conversation" by the signed-in user, and so does the Remember plugin's session memory, which also ignores a session id that a 2026-07-28 client sends.

Here's a tool that asks the user before it closes a ticket. The client is McpStatelessClient, FrontMCP's own 2026-07-28 client, which answers input_required results with its handlers and sends the request again. The Playground runs the real FrontMCP SDK in your browser; open the Tests tab to run the checks:

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("server/discover lists the revisions, newest first", async () => {
  const desk = await connect();
  expect((await desk.discover()).supportedVersions).toEqual(["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]);
});

test("the tool's question comes back as input_required, and the client answers it", 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("a client that doesn't declare elicitation gets -32021", async () => {
  const desk = await connect();
  await expect(desk.callTool("close_ticket", { id: "T-1" })).rejects.toMatchObject({ code: -32021 });
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The tool's code is the same in both eras: this.elicit() sends a request to an older client, and returns input_required to a 2026-07-28 one. The last test shows the capability rule: a client that doesn't say it can answer a question can't call a tool that asks one.

Protocol versions has every change and what FrontMCP does with it.

Moving a server to 2026-07-28

Whatever framework you use, the same list applies:

  1. Find state kept in the session. Maps keyed by session id, caches per connection, "remember for this conversation" flags. Key them by the signed-in user, or return a handle the model passes back.
  2. Ask before you act. A tool that asks the user runs again from the top when the answer comes back, so do the asking before any write.
  3. Share secrets across instances. Whatever signs the input_required state has to be the same on every instance.
  4. Ask for notifications per request. A client that wants progress or log messages asks on the request, and a server mustn't send log messages to a request that didn't ask.
  5. Move change notifications to subscriptions/listen, and stop relying on the GET stream.
  6. Handle the new error codes, especially -32602 for a missing resource, which used to be -32002.
  7. Keep serving the older revisions until your clients have moved, and test both: a client that starts with initialize, and one that sends server/discover and tools/call with the version in _meta.

Code in this post targets the FrontMCP version current when it was written; see Versions for what changed since.