The MCP 2026–07–28 Spec Just Dropped — Here’s How to Build a Production-Ready MCP Server with… Anthropic's Model Context Protocol (MCP) 2026-07-28 release candidate, the largest revision since launch, makes MCP stateless at the protocol layer by removing the initialize handshake and session IDs, enabling servers to sit behind plain round-robin load balancers. The update introduces new routing headers, built-in caching with ttlMs and cacheScope, distributed tracing via W3C Trace Context, and first-class extensions for MCP Apps and Tasks, with 97 million monthly SDK downloads and 13,000+ servers on GitHub. If you’ve been building AI-powered applications in 2026, you’ve felt the gravitational pull of the Model Context Protocol. What started as Anthropic’s open-source experiment in late 2024 has become the de facto standard for connecting AI models to external tools, data, and services. The numbers speak for themselves — 97 million monthly SDK downloads, 13,000+ servers on GitHub, and native support from Claude, ChatGPT, Cursor, VS Code Copilot, and virtually every major AI platform. But here’s what makes right now a pivotal moment: the MCP 2026-07-28 release candidate — the largest revision of the protocol since launch — just landed. And it changes everything about how you architect MCP servers. The headline? MCP is now stateless at the protocol layer. The initialize/initialized handshake is gone. The Mcp-Session-Id header is gone. Sticky sessions and shared session stores are gone. Your MCP server can now sit behind a plain round-robin load balancer, and any instance can handle any request. If you’re an Express.js developer, this is your moment. The new spec essentially turns MCP servers into something that behaves remarkably like the REST APIs you’ve been building for years. Let me show you how to build one — properly. Before we write any code, let’s understand the architectural shift. If you’ve touched MCP before, you’ll remember the pain: establishing sessions, maintaining sticky routes, dealing with SSE streams, and sharing session state across instances. The old flow looked like this: Client → initialize handshake → get Mcp-Session-Id → pin to one server instance → every subsequent request carries that session ID → need sticky sessions + shared session store for horizontal scaling The new flow: Client → POST /mcp with self-contained request → any server instance handles it → done Six Specification Enhancement Proposals SEPs worked together to achieve this. Here’s what matters to you as a developer: 1. The handshake is dead. No more initialize/initialized exchange. Protocol version, client info, and capabilities now travel in meta on every request. A new server/discover method lets clients fetch server capabilities on demand. 2. Sessions are gone. Mcp-Session-Id is removed entirely. Any request can land on any server instance. If your application needs state across calls, you use the explicit handle pattern — mint a handle like a basket id or session token from a tool and have the model pass it back as an argument in later calls. The model is actually great at this. 3. New routing headers. Requests now carry Mcp-Method and Mcp-Name headers, so load balancers and API gateways can route without inspecting the body. The server rejects requests where headers and body disagree. 4. Built-in caching. List and resource results now carry ttlMs and cacheScope, modeled on HTTP Cache-Control. No more holding SSE streams open just to detect when a tool list changes. 5. Distributed tracing. W3C Trace Context propagation in meta is now spec'd, so a trace that starts in the host application follows tool calls across your entire infrastructure. 6. Extensions are first-class. MCP Apps server-rendered UIs in sandboxed iframes and the Tasks extension for long-running async operations ship as official extensions with their own lifecycle. The practical takeaway? Building an MCP server now feels like building any other HTTP microservice. And if your tool of choice is Express.js, you’re going to feel right at home. Before we jump into code, let’s talk about how a well-structured MCP server project should look. Most tutorials dump everything into a single index.ts. That's fine for a demo. It's not fine for production. Here’s the project structure we’re building: mcp-server/├── src/│ ├── index.ts Express app bootstrap + HTTP layer│ ├── server.ts MCP server setup + tool/resource registration│ ├── tools/│ │ ├── index.ts Tool registry barrel export │ │ ├── search.tool.ts Individual tool definition│ │ └── summarize.tool.ts Individual tool definition│ ├── resources/│ │ └── docs.resource.ts Resource definitions│ ├── middleware/│ │ ├── logging.ts Request logging│ │ └── error-handler.ts Centralized error handling│ └── lib/│ └── validation.ts Shared validation utilities├── tsconfig.json├── package.json└── .env Each tool lives in its own file. The MCP server logic is decoupled from the HTTP transport layer. Middleware handles cross-cutting concerns. This is the same separation-of-concerns philosophy you’d apply to any Express API — and that’s exactly the point. Initialize the project and install dependencies: mkdir mcp-express-server && cd mcp-express-servernpm init -y Install production dependencies: npm install @modelcontextprotocol/sdk @modelcontextprotocol/express zod express dotenv Install dev dependencies: npm install -D typescript @types/node @types/express A few things to note here. The @modelcontextprotocol/sdk package is the official TypeScript SDK. The @modelcontextprotocol/express package is the official Express middleware adapter — it's intentionally thin and handles Host header validation and DNS rebinding protection out of the box. zod handles runtime input validation for tool parameters — the SDK uses Zod schemas natively, giving you automatic type checking and descriptive error messages. Configure TypeScript. This is where most people get tripped up: // tsconfig.json{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "sourceMap": true }, "include": "src/ / " , "exclude": "node modules", "build" } The critical settings are "module": "Node16" and "moduleResolution": "Node16". The MCP SDK is ESM-only. Using CommonJS or NodeNext without the right resolution strategy will produce cryptic import failures. Also, make sure your package.json includes: { "type": "module", "scripts": { "build": "tsc", "start": "node build/index.js", "dev": "tsc --watch & node --watch build/index.js" }} Tools are the core primitive. They’re functions the model can invoke — it reads the name, description, and JSON Schema to decide when and how to call them. Let’s define two tools with clean separation. python // src/tools/search.tool.tsimport { z } from "zod";import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";export const searchToolSchema = { query: z.string .min 1 .describe "The search query string" , maxResults: z .number .int .min 1 .max 20 .default 5 .describe "Maximum number of results to return" ,};export function registerSearchTool server: McpServer : void { server.tool "search documents", "Search internal documents by keyword. Returns matching titles, snippets, and relevance scores.", searchToolSchema, async { query, maxResults } = { // Replace with your actual search logic - Elasticsearch, Postgres full-text, etc. const results = await performSearch query, maxResults ; return { content: { type: "text" as const, text: JSON.stringify results, null, 2 , }, , }; } ;}async function performSearch query: string, maxResults: number : Promise