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

> MCP 2026-07-28 removed initialize and sessions. What changed in the protocol, what it means for an MCP server, which TypeScript frameworks serve the new revision, the old ones or both, and how to move a server over.

Source: https://frontmcp.dev/blog/mcp-went-stateless

*[Illustration: Up to MCP 2025-11-25, a client sends initialize, gets an Mcp-Session-Id back, and sends that id with every tools/call. In 2026-07-28, a client sends tools/call with its protocol version and capabilities in _meta, and gets a result whose resultType is complete: no handshake, no session.]*
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](https://modelcontextprotocol.io/specification/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.

*[Illustration: The input_required round trip in MCP 2026-07-28. The MCP client, which declares elicitation, sends tools/call close_ticket to instance A. The tool calls this.elicit('Close ticket T-1?'), so instance A answers input_required with a requestState. The user confirms, and the client sends tools/call close_ticket again with inputResponses and the requestState, this time to instance B, which checks the requestState with the shared key, runs the tool again from the top, and answers resultType complete with status closed.]*
**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

*[Illustration: Who serves MCP 2026-07-28, as each server answered on 2026-10-20. Official TypeScript SDK 2.3.1: 2026-07-28 over HTTP, opt-in, and over stdio, and the older revisions. FastMCP (punkpeye) 4.23.4: not 2026-07-28 (400 over HTTP, -32601 over stdio); the older revisions. ViteMCP 1.9.5: 2026-07-28 over HTTP and stdio, and the older revisions in our run. FastMCP TypeScript (Prefect) 1.7.3: all three. mcp-use 2.8.2: 2026-07-28 over HTTP, no stdio server, the older revisions. xmcp 1.7.1: all three. mcp-framework 0.2.22: not 2026-07-28 (400, -32601); the older revisions. FrontMCP 1.10.5: all three, stdio since 1.10.5.]*
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 checked | MCP revisions | 2026-07-28 over HTTP | 2026-07-28 over stdio | Sessions for older clients, over HTTP |
| --- | --- | --- | --- | --- |
| [Official TypeScript SDK](https://frontmcp.dev/compare/official-typescript-sdk) 2.3.1 | 2024-11-05 to 2026-07-28; 2026-07-28 is opt-in ([Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions)) | Yes, from `createMcpHandler()` | Yes, from `serveStdio()` | None from `createMcpHandler()`; one from a transport wired by hand ([HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http)) |
| [FastMCP](https://frontmcp.dev/compare/fastmcp-typescript) (punkpeye) 4.23.4 | 2024-11-05 to 2025-11-25; the README says it doesn't support 2026-07-28 ([README](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md?plain=1#L5-L7)) | No: `400` | No: `-32601` | Yes; none in stateless mode ([Stateless mode](https://github.com/punkpeye/fastmcp/blob/v4.23.4/README.md#stateless-mode)) |
| [ViteMCP](https://frontmcp.dev/compare/vitemcp) 1.9.5 | 2024-11-05 to 2026-07-28 in our run; the README says 2026-07-28 only ([README](https://github.com/vitemcp/server/blob/v1.9.5/README.md?plain=1#L1499-L1504)) | Yes | Yes | None, in our run |
| [FastMCP TypeScript](https://frontmcp.dev/compare/fastmcp-ts) (Prefect) 1.7.3 | 2024-11-05 to 2026-07-28 ([Protocol eras](https://github.com/PrefectHQ/fastmcp-ts/blob/v1.7.3/docs/concepts/protocol-eras.mdx)) | Yes | Yes | Yes, in the instance's memory; none in stateless mode ([Stateless mode](https://github.com/PrefectHQ/fastmcp-ts/blob/v1.7.3/docs/servers/running.mdx#stateless-mode)) |
| [mcp-use](https://frontmcp.dev/compare/mcp-use) 2.8.2 | 2024-11-05 to 2026-07-28 ([Server overview](https://docs.mcp-use.com/v2/typescript/server/index)) | Yes | No stdio server | None ([Migration](https://docs.mcp-use.com/v2/typescript/server/migration)) |
| [xmcp](https://frontmcp.dev/compare/xmcp) 1.7.1 | 2024-11-05 to 2026-07-28 ([Transports](https://xmcp.dev/docs/configuration/transports)) | Yes | Yes | None ([Transports](https://xmcp.dev/docs/configuration/transports)) |
| [mcp-framework](https://frontmcp.dev/compare/mcp-framework) 0.2.22 | 2024-11-05 to 2025-11-25 ([Introduction](https://mcp-framework.com/docs/introduction)) | No: `400` | No: `-32601` | Yes, in the process ([issue #187](https://github.com/QuantGeekDev/mcp-framework/issues/187)) |
| [FrontMCP](https://frontmcp.dev/learn) 1.10.5 | 2024-11-05 to 2026-07-28 ([Protocol versions](https://frontmcp.dev/reference/server/protocol-versions)) | Yes | Yes, since 1.10.5 | Yes, which Redis can share between instances ([Redis](https://frontmcp.dev/reference/deployment/redis)) |

"Yes" and "No" are what each server answered: a `server/discover` and a 2026-07-28 `tools/call`, with no `initialize`. [Compare](https://frontmcp.dev/compare#how-this-was-checked) 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](https://frontmcp.dev/reference/plugins/approval) keys an approval given "for this conversation" by the signed-in user, and so does the [Remember plugin](https://frontmcp.dev/reference/plugins/remember)'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:

```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("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 });
});
```

```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 {}
```

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](https://frontmcp.dev/reference/server/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`.
