Back to Blog
DevelopmentJul 29, 202613 min read

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

NT

Nikhil Tiwari

MCP Playground

๐Ÿ“– 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 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 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 instead.

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 has the exhaustive list.

2025-11-25 (old) 2026-07-28 (new)
initialize handshakeNone โ€” version rides in _meta
Capabilities from the handshakeserver/discover (a MUST)
Sampling, Roots, LoggingDeprecated โ€” use MRTR and stderr
No cache signallingttlMs + cacheScope on lists
HTTP GET streamsubscriptions/listen
Missing resource is -32002Missing resource is -32602

One more that matters for tool design: tool schemas now support full JSON Schema 2020-12. 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. 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 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 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.

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 covers the platform differences, and there is a dedicated Cloudflare Workers walkthrough.

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 โ†’

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 validation is now required, not optional. See my MCP OAuth 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 โ†’

The full threat model is in my MCP server security guide.

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.

How MCP Playground can help

MCP Playground 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 before you deploy.

NT

Written by Nikhil Tiwari

15+ years in product development. AI enthusiast building developer tools that make complex technologies accessible to everyone.

Build, compare & ship MCP agents โ€” free

Connect any MCP server, compare 40+ models side-by-side, deploy hosted servers, and save reusable agents you can export as an API โ€” all in your browser.

โœฆ Free credits on sign-up ยท no credit card needed

Try for Free โ†’
Build an MCP Server on the 2026-07-28 Spec: The Stateless Guide