# Build an MCP Server on the 2026-07-28 Spec: The Stateless Guide

> The 2026-07-28 spec shipped final and MCP is stateless now. If you build a new MCP server on the old handshake, you are starting on a deprecated foundation. Here is the from-scratch build, with real code.

**Source:** https://mcpplaygroundonline.com/blog/build-mcp-server-2026-spec  
**Author:** Nikhil Tiwari  
**Published:** 2026-07-29  
**Category:** Development  
**Reading time:** 13 min read

---

📖 TL;DR

-   The **2026-07-28 MCP spec** shipped final on July 28, 2026. New servers should start here, not on 2025-11-25.
-   **No `initialize` handshake, no `Mcp-Session-Id`.** Every request is self-contained, so your server runs behind a plain load balancer.
-   Build on the new **`@modelcontextprotocol/server` v2** package. The old `@modelcontextprotocol/sdk` v1 line stays alive alongside it.
-   `server/discover` is a **MUST**. The SDK answers it for you, and stamps `resultType`, `ttlMs`, and `cacheScope`.
-   One handler can serve **both spec revisions**, so old clients keep working while you ship on the new one.
-   Point a real model at it in [MCP Playground](/mcp-test-server) before you deploy.

If you **build an MCP server** this week, you have a decision to make on line one.

The 2026-07-28 spec shipped final yesterday. It is the biggest revision since [_Model Context Protocol_](/blog/what-is-model-context-protocol) launched.

Start on the old 2025-11-25 handshake and you are writing a migration ticket for yourself before your first deploy.

Start on the new spec and a lot of the hard parts disappear. **No session store. No sticky routing. No handshake round trip.**

I have spent the last week porting our own mock servers onto the v2 SDK. Some of it is genuinely simpler. Some of it has sharp edges nobody has written about yet.

This is the from-scratch build guide — real code from a real server, not a hello-world snippet. If you already run a 2025-era server, read my [2026-07-28 migration checklist](/blog/migrate-mcp-server-2026-07-28-stateless) instead.

📑 Table of Contents

1.  [What is different in the 2026-07-28 spec](#whats-different)
2.  [Pick the right SDK package](#choose-sdk)
3.  [Build the server, step by step](#build-server)
4.  [Serve both revisions from one endpoint](#dual-era)
5.  [Multi Round-Trip Requests](#mrtr)
6.  [The new security hole: requestState](#security)
7.  [Test before you ship](#testing)
8.  [FAQ](#faq)

## What is different when you build an MCP server on the 2026-07-28 spec

The old protocol was stateful. A client opened with an `initialize` handshake, got an `Mcp-Session-Id`, then carried it on every later request.

That one design choice leaked into your whole deployment. **Sticky routing, a shared session store, and a gateway that had to read request bodies to route them.**

The 2026-07-28 revision deletes it. Each request carries the protocol version and client info in `_meta` fields instead.

The practical result: **any instance can serve any request.** Round-robin works. Serverless works. Cold starts stop being a session problem.

Here is what actually changes on the server you are about to write. The [official changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog) has the exhaustive list.

2025-11-25 (old)

2026-07-28 (new)

`initialize` handshake

None — version rides in `_meta`

Capabilities from the handshake

`server/discover` (a MUST)

Sampling, Roots, Logging

Deprecated — use MRTR and stderr

No cache signalling

`ttlMs` + `cacheScope` on lists

HTTP GET stream

`subscriptions/listen`

Missing resource is `-32002`

Missing resource is `-32602`

One more that matters for tool design: **tool schemas now support full [JSON Schema 2020-12](https://json-schema.org/draft/2020-12/release-notes).** You get `oneOf`, `anyOf`, `allOf`, and `$ref`.

If you were flattening a complex input into a bag of optional strings to fit the old limits, stop doing that.

## Pick the right SDK package before you write code

This trips people up on day one, so get it right first.

The v2 SDK did **not** ship as a major bump of the old package. It shipped as **new packages** that live alongside it.

-   `@modelcontextprotocol/server` v2 — build a 2026-07-28 server.
-   `@modelcontextprotocol/client` v2 — build a client that speaks both eras.
-   `@modelcontextprotocol/sdk` v1 — the 2025-era line, still maintained.

All three live in the [official TypeScript SDK repo](https://github.com/modelcontextprotocol/typescript-sdk). Python, Go, and C# shipped 2026-07-28 support on launch day too.

They coexist in one `package.json`. No fork, no vendoring, no pinning games. That is what makes an incremental rollout possible.

**The gotcha nobody warns you about:** the v2 SDK takes Standard Schema validators that expose a `jsonSchema` property. Only [zod 4](https://zod.dev) does. If your repo is still on zod 3, install zod 4 under an alias rather than upgrading everything at once — that is exactly what I did.

Install what you need:

```
npm install @modelcontextprotocol/server zod@^4
```

## Build the MCP server, step by step

I will build a small server with two tools, then wire it to HTTP. Every snippet below is the shape I run in production.

### Step 1: Define the server and its cache hints

The server object takes identity in the first argument and capabilities in the second.

**Cache hints are new and worth setting.** Without them the SDK emits `ttlMs: 0` and `cacheScope: 'private'` — compliant, but it tells clients nothing.

server.ts

```
import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';

export function createServer() {
  const server = new McpServer(
    {
      name: 'my-mcp-server',
      version: '1.0.0',
      title: 'My MCP Server',
    },
    {
      capabilities: { tools: {} },
      cacheHints: {
        'tools/list': { ttlMs: 60_000, cacheScope: 'public' },
        'server/discover': { ttlMs: 300_000, cacheScope: 'public' },
      },
    }
  );

  return server;
}
```

Pick `cacheScope` carefully. **Use `'public'` only when the response is identical for every caller.** A tool list that varies by tenant must stay `'private'`.

Get that wrong and a shared intermediary can serve one customer's tool list to another. I come back to this in the security section.

### Step 2: Register tools with real schemas

Tools are registered with a name, a description, an input schema, and a handler.

**The description is not documentation — it is the prompt.** It is what the model reads when deciding whether to call your tool. The [official tools concept doc](https://modelcontextprotocol.io/docs/concepts/tools) is worth a skim here.

server.ts (continued)

```
server.registerTool(
  'echo',
  {
    description: 'Echoes back the input message',
    inputSchema: z.object({
      message: z.string().describe('The message to echo back'),
    }),
  },
  async ({ message }) => ({
    content: [{ type: 'text', text: message }],
  })
);

server.registerTool(
  'calculator',
  {
    description: 'Performs basic arithmetic operations',
    inputSchema: z.object({
      operation: z
        .enum(['add', 'subtract', 'multiply', 'divide'])
        .describe('The arithmetic operation to perform'),
      a: z.number().describe('First number'),
      b: z.number().describe('Second number'),
    }),
  },
  async ({ operation, a, b }) => {
    if (operation === 'divide' && b === 0) {
      throw new Error('Division by zero');
    }
    const result = { add: a + b, subtract: a - b, multiply: a * b, divide: a / b }[operation];
    return { content: [{ type: 'text', text: 'Result: ' + result }] };
  }
);
```

Two habits that pay off later:

-   **Put `.describe()` on every field.** It lands in the JSON Schema the model sees, and it is the difference between a right argument and a guessed one.
-   **Keep tool order deterministic.** The spec says SHOULD, and it is what makes client-side caching of `tools/list` actually work.

If you want the deeper version of this, I wrote up how models actually choose tools in [MCP agent tool calling](/blog/what-is-mcp-agent-tool-calling).

### Step 3: Serve it over HTTP

This is where the stateless design earns its keep. **No transport object to keep alive, no session map.**

`createMcpHandler` takes a factory. It builds a fresh server instance per request and hands you the era it classified the request into.

route.ts — Next.js App Router

```
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createServer } from './server';

const handler = createMcpHandler(({ era }) => createServer(era), {
  legacy: 'stateless',
  onerror: (error) => console.error('[mcp] handler error:', error),
});

export async function POST(req: Request) {
  return handler.fetch(req);
}
```

That is the whole server. **It runs on Vercel, Cloudflare Workers, Lambda, or a plain Node box** with no shared state between instances.

Notice what you did not write. No `initialize` handler. No `server/discover` handler either — **the SDK answers that from your registered tools.**

It also stamps `resultType` on every result and applies your cache hints. Those are MUSTs in the new spec, and you get them free.

Deploying somewhere specific? My [MCP server deployment guide](/blog/deploy-mcp-server-vercel-railway-render-heroku-flyio) covers the platform differences, and there is a dedicated [Cloudflare Workers walkthrough](/blog/build-mcp-server-cloudflare-workers-guide).

Server running locally?

Paste the URL and watch a real AI model call your tools — in the browser, no install.

[Test any MCP server free →](/mcp-test-server)

## Serve both spec revisions from one endpoint

Here is the reality of shipping in July 2026. **Most clients in the wild still speak 2025-11-25.**

You do not have to choose. That `legacy: 'stateless'` option in the handler is doing real work.

With it, one endpoint classifies each request and answers correctly. An `initialize` body routes to the legacy leg. A `_meta` version envelope routes to the stateless leg.

**One server definition, both eras.** They cannot drift apart because they come from the same factory — that is the pattern the v2 SDK explicitly recommends.

You can also pin the behaviour when you want to test rejection paths:

-   `legacy: 'stateless'` — serve both. The sane default.
-   `legacy: 'reject'` — 2026-07-28 only. Old handshakes get `-32022 UnsupportedProtocolVersion`.

Pinning to reject is useful in CI. It proves your server is genuinely compliant rather than quietly leaning on the fallback.

## Multi Round-Trip Requests: what replaced sampling and elicitation

The old spec let your server call back into the client. Sampling asked the client to run the model. Elicitation asked the user a question. Roots asked for the workspace.

**All of that is deprecated**, because a stateless server has no open channel to call back on.

The replacement is _Multi Round-Trip Requests_, or MRTR. It inverts the flow, and it is simpler than it sounds.

Your handler returns an `inputRequired` result instead of a normal one. The client gathers answers, then **re-issues the original request** with the answers attached.

A confirm-before-acting tool

```
import { inputRequired, acceptedContent } from '@modelcontextprotocol/server';

server.registerTool(
  'deploy',
  {
    description: 'Deploys the app. Asks for confirmation first.',
    inputSchema: z.object({ target: z.string() }),
  },
  async ({ target }, ctx) => {
    const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm');

    if (!answer) {
      return inputRequired({
        inputRequests: {
          confirm: {
            message: 'Deploy to ' + target + '?',
            schema: z.object({ confirm: z.boolean() }),
          },
        },
      });
    }

    return { content: [{ type: 'text', text: 'Deployed to ' + target }] };
  }
);
```

The same handler works on both eras. **On a 2025-era connection the SDK's legacy shim turns it into a real server-to-client request**, so you write it once.

This is the pattern to reach for on any **write-heavy tool** — deploys, deletes, payments. Confirmation belongs in the protocol, not in a description string begging the model to ask first.

## The new security hole: unsigned requestState

MRTR introduces a genuinely new attack surface, and almost nobody is handling it yet.

When your handler returns `inputRequired`, it can attach a `requestState` blob to remember what it was doing. That blob **round-trips through the client**.

Think about what that means. It comes back as **attacker-controlled input**. If it carries a user ID, a target environment, or an authorization decision, it is tamperable.

The SDK does not sign it for you. You have to opt in.

Seal requestState with an HMAC

```
import { createRequestStateCodec } from '@modelcontextprotocol/server';

const stateCodec = createRequestStateCodec({
  key: process.env.MCP_REQUEST_STATE_KEY,
  ttlSeconds: 300,
});
```

**Use a shared secret from the environment, not a per-process random key.** A random key only works while one instance serves every round of a flow — which defeats the point of going stateless.

Two more checks worth running before launch:

-   **`cacheScope: 'public'` on tenant-scoped data.** This leaks one user's data to another through shared caches. Audit every hint you set.
-   **Missing `iss` validation in auth.** [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) validation is now required, not optional. See my [MCP OAuth guide](/blog/mcp-server-oauth-authentication-guide).

**Do not ship an MCP server without scanning it.** Tool poisoning, prompt injection through tool descriptions, and unsigned `requestState` are all invisible in a normal test run.

[Scan your MCP server →](/mcp-security-scanner)

The full threat model is in my [MCP server security guide](/blog/mcp-server-security-complete-guide-2026).

## Test your MCP server before you ship it

A server that returns valid JSON is not a server that works.

The failure I see most often is not a protocol error. It is **a model that reads your tool description and picks the wrong tool** — or the right tool with the wrong arguments.

That bug is invisible to curl. It only shows up when a real model is driving.

My pre-ship checklist:

1.  Confirm `server/discover` answers and lists every tool you registered.
2.  Check `resultType`, `ttlMs`, and `cacheScope` appear on list results.
3.  Pin a client to `2026-07-28` and confirm nothing depends on the fallback.
4.  Pin to the legacy era and confirm old clients still work.
5.  Run a plain-English prompt through a real model and watch which tool it picks.
6.  Break something on purpose — bad arguments, a thrown error — and check the model recovers.

Step five is the one people skip, and it is the one that catches the expensive bugs. There is a longer walkthrough in [how to test MCP servers step by step](/blog/how-to-test-mcp-servers-step-by-step).

## How MCP Playground can help

[MCP Playground](/mcp-test-server) connects your server to a live AI model in the browser, with no local install. It speaks both spec revisions, shows you the negotiated one, and lets you force either. You see every tool list, every call with its arguments, and every error — so you find the wrong-tool bug before your users do.

## Frequently Asked Questions

**Should I build a new MCP server on the 2026-07-28 spec?** Yes. It shipped final on July 28, 2026, and one handler serves old clients too, so there is no compatibility reason to start on the old revision.

**Which package do I install?** `@modelcontextprotocol/server` v2. The v1 `@modelcontextprotocol/sdk` package is a separate line and can sit in the same project.

**Do I have to implement `server/discover` myself?** No. It is a MUST in the spec, but the v2 SDK answers it from your registered tools, prompts, and resources.

**What replaced sampling and elicitation?** Multi Round-Trip Requests. Your handler returns `inputRequired`, the client collects the answers, and it re-issues the original request.

## Conclusion

**Building on the 2026-07-28 spec is less work than building on the old one.** No session store, no sticky routing, and `server/discover`, `resultType`, and cache hints come from the SDK.

The two things you own are cache scope and a signed `requestState`. Get those right and the rest is tool design.

Then prove it works against a real model. [Test any MCP server free](/mcp-test-server) before you deploy.

## Frequently asked questions

### Should I build a new MCP server on the 2026-07-28 spec?

Yes. The 2026-07-28 specification shipped final on July 28, 2026, and it is the current revision. A single handler can serve both the stateless 2026-07-28 era and the older 2025-11-25 handshake, so there is no backward-compatibility reason to start a new server on the old revision.

### Which SDK package do I use to build a 2026-07-28 MCP server?

Install @modelcontextprotocol/server version 2. The v2 SDK shipped as new packages rather than a major bump, so the older @modelcontextprotocol/sdk v1 line is still maintained and both can coexist in one package.json. Note that the v2 SDK requires a Standard Schema validator exposing a jsonSchema property, which currently means zod 4.

### Do I need to implement server/discover myself?

No. Implementing server/discover is a MUST in the 2026-07-28 spec, but the v2 SDK answers it automatically from your registered tools, prompts, and resources. The SDK also stamps resultType on every result and applies any ttlMs and cacheScope cache hints you configure.

### What replaced sampling, elicitation, and roots in the 2026-07-28 spec?

Multi Round-Trip Requests, or MRTR. Instead of the server calling back into the client, your handler returns an inputRequired result. The client gathers the answers and re-issues the original request with those answers plus the echoed requestState. The same handler works on 2025-era connections through the SDK legacy shim.

### What is the requestState security risk in MCP?

The requestState blob attached to an inputRequired result round-trips through the client, so it comes back as attacker-controlled input. If it influences authorization or business logic and is not integrity-protected, an attacker can tamper with it. Sign it with an HMAC using createRequestStateCodec and a shared secret from the environment, not a per-process random key.

### Can one MCP server support both the 2025 and 2026 spec revisions?

Yes. Pass legacy: stateless to createMcpHandler and one endpoint classifies each request by its shape: an initialize body routes to the legacy leg, and a _meta protocol-version envelope routes to the stateless leg. Both are built from the same server factory, so they cannot drift apart.


---

_Canonical page: https://mcpplaygroundonline.com/blog/build-mcp-server-2026-spec — MCP Playground (mcpplaygroundonline.com), the free browser-based tool for testing MCP servers and building AI agents._
