The MCP 2026-07-28 release candidate marks the largest architectural shift in the protocol's history: sessions are gone. No more initialize handshake, no more Mcp-Session-Id header, no more sticky routing at your load balancer.
This tutorial builds a knowledge-base MCP server from scratch using the beta TypeScript SDK. You will implement the stateless transport, capability discovery, async Tasks, and Multi Round-Trip Requests for user confirmations — the four patterns that define the new protocol.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed (
node --version) - TypeScript familiarity — async/await, generics
- A basic understanding of MCP concepts — if you are new to MCP, start with our MCP Server TypeScript guide first
- A terminal and a code editor
What You'll Build
A KnowledgeBase MCP server that AI agents can use to:
- search-docs — full-text search over a documentation index
- get-article — retrieve a single article by ID
- create-article — write a new article, requiring user confirmation via Multi Round-Trip Request
- export-collection — zip and export a tag-filtered collection as an async Task
By the end you will have a server that runs identically on any instance, scales horizontally behind a plain round-robin load balancer, and handles long-running exports without blocking the HTTP connection.
Step 1: Project Setup
Create the project and install the 2026-07-28 beta SDK:
mkdir kb-mcp-server && cd kb-mcp-server
pnpm init
pnpm add @modelcontextprotocol/server@beta zod
pnpm add -D typescript tsx @types/nodeInitialize TypeScript:
npx tsc --init \
--target ES2023 \
--module NodeNext \
--moduleResolution NodeNext \
--strictSet "type": "module" in package.json, then create your source directory:
mkdir src && touch src/index.tsAdd a start script to package.json:
{
"scripts": {
"start": "tsx src/index.ts",
"build": "tsc"
}
}SDK version: These examples target @modelcontextprotocol/server@2.0.0-beta.x. Run pnpm list @modelcontextprotocol/server to confirm. The v2 beta is backward-compatible — v2 servers respond correctly to v1 clients, so you can migrate on your own schedule.
Step 2: A Minimal Stateless Server
The most visible change in v2 is what is absent: there is no initialize handler, no session creation, and no Mcp-Session-Id to track. Every tool call is self-contained. The article IDs come in the request body; the response goes back on the same HTTP connection.
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/server/mcp.js";
import { StreamableHttpServerTransport } from "@modelcontextprotocol/server/streamable-http.js";
import { z } from "zod";
import http from "node:http";
// In-memory store for the tutorial. Swap for a real database in production.
const articles = new Map([
["001", { title: "Getting Started with MCP", content: "MCP is a protocol...", tags: ["mcp", "intro"] }],
["002", { title: "Stateless Design Patterns", content: "Stateless services eliminate...", tags: ["architecture"] }],
]);
const server = new McpServer({
name: "kb-server",
version: "2.0.0",
});
server.registerTool(
"search-docs",
{
description: "Full-text search over the knowledge base.",
inputSchema: z.object({
query: z.string().describe("Search terms"),
limit: z.number().int().min(1).max(20).default(5),
}),
},
async ({ query, limit }) => {
const q = query.toLowerCase();
const results = [...articles.entries()]
.filter(([, a]) => a.title.toLowerCase().includes(q) || a.content.toLowerCase().includes(q))
.slice(0, limit)
.map(([id, a]) => ({ id, title: a.title, tags: a.tags }));
return {
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
};
}
);
server.registerTool(
"get-article",
{
description: "Retrieve a single article by its ID.",
inputSchema: z.object({
id: z.string().describe("Article ID"),
}),
},
async ({ id }) => {
const article = articles.get(id);
if (!article) {
return {
isError: true,
content: [{ type: "text", text: `Article ${id} not found` }],
};
}
return {
content: [{ type: "text", text: JSON.stringify({ id, ...article }, null, 2) }],
};
}
);
// Transport wired up in the next stepStep 3: Stateless Transport and server/discover
In v1, the initialize handshake told the client what the server could do. In v2 that negotiation happens on demand via the new server/discover method. The beta SDK handles the routing automatically — you just enable stateless: true on the transport.
Add the HTTP listener to src/index.ts:
const transport = new StreamableHttpServerTransport({ stateless: true });
await server.connect(transport);
const httpServer = http.createServer(async (req, res) => {
// The transport routes server/discover, tools/call, tasks/get, etc.
await transport.handleRequest(req, res);
});
httpServer.listen(3000, () => {
console.log("KB MCP server → http://localhost:3000/mcp");
});Setting stateless: true tells the transport to skip session tracking entirely. Requests without Mcp-Session-Id are now the happy path, not a fallback.
Test it with the beta MCP Inspector:
npx @modelcontextprotocol/inspector@beta http://localhost:3000/mcpOpen the network tab — you will see tools listed after a single server/discover call, with no initialize exchange before it.
Step 4: Client Metadata via _meta
In v1, client information (name, version, capabilities) arrived once during initialize and was stored in session state. In v2 it travels in the _meta field of every request.
Access it in any tool handler through the second argument:
server.registerTool(
"get-article",
{
description: "Retrieve a single article by its ID.",
inputSchema: z.object({ id: z.string() }),
},
async ({ id }, context) => {
const clientName = context.meta?.clientInfo?.name ?? "unknown";
const clientVersion = context.meta?.clientInfo?.version ?? "?";
console.log(`[${clientName}@${clientVersion}] get-article id=${id}`);
const article = articles.get(id);
if (!article) {
return { isError: true, content: [{ type: "text", text: `Not found: ${id}` }] };
}
return { content: [{ type: "text", text: JSON.stringify({ id, ...article }) }] };
}
);Every request is independently auditable. You can rate-limit per client, log per client, and enforce per-client quotas without any shared session store.
Step 5: Tasks Extension — Async Exports
The export-collection tool may take tens of seconds for large collections. Blocking the HTTP connection that long is not acceptable. The Tasks extension lets you return a task handle immediately and let the client poll.
The v2 SDK provides TasksExtension to manage this lifecycle:
import { TasksExtension } from "@modelcontextprotocol/server/extensions/tasks.js";
const tasks = new TasksExtension(server);
server.registerTool(
"export-collection",
{
description: "Export all articles matching a tag as a JSON file.",
inputSchema: z.object({
tag: z.string().describe("Tag to filter articles by"),
}),
},
async ({ tag }) => {
const task = tasks.create(async (taskCtx) => {
await taskCtx.updateStatus("running", "Collecting articles...");
const matching = [...articles.entries()].filter(([, a]) => a.tags.includes(tag));
await taskCtx.updateStatus("running", `Found ${matching.length} articles. Packaging...`);
// Simulate async work (database query, zip compression, upload, etc.)
await new Promise((r) => setTimeout(r, 2000));
const payload = JSON.stringify(Object.fromEntries(matching), null, 2);
await taskCtx.complete({ data: payload, filename: `${tag}-export.json` });
});
// Return the task handle immediately — the client polls tasks/get
return { type: "task", taskId: task.id };
}
);The client receives taskId in under a millisecond, then calls tasks/get at its own pace until the status is completed.
tasks/list is intentionally absent from v2. Without sessions there is no safe definition of "all in-flight tasks for this client." Pass the taskId through your agent's context variables instead of relying on server-side task enumeration.
Step 6: Multi Round-Trip Requests (MRTR)
Writing a new article is a meaningful action — it changes shared state and should require confirmation. MRTR lets a tool pause mid-execution, surface a prompt to the user, and resume with their answer.
Return InputRequiredResult to pause the tool:
import { InputRequiredResult } from "@modelcontextprotocol/server/mcp.js";
server.registerTool(
"create-article",
{
description: "Create a new article in the knowledge base.",
inputSchema: z.object({
title: z.string(),
content: z.string(),
tags: z.array(z.string()),
confirmed: z.boolean().optional().describe("Pass true after user confirms creation"),
}),
},
async ({ title, content, tags, confirmed }) => {
if (!confirmed) {
return new InputRequiredResult({
message: `Create article "${title}" with tags [${tags.join(", ")}]? Reply yes or no.`,
schema: z.object({ confirmed: z.boolean() }),
});
}
const id = String(articles.size + 1).padStart(3, "0");
articles.set(id, { title, content, tags });
return {
content: [{ type: "text", text: `Created article ${id}: "${title}"` }],
};
}
);When the tool returns InputRequiredResult, the MCP client surfaces the message to the user. The user answers; the client retries the call with confirmed: true. No bespoke state machine required on your side.
Step 7: Gateway Headers for Smart Routing
Two new transport headers — Mcp-Method and Mcp-Name — allow API gateways to route requests by method and tool name without parsing the JSON-RPC body. This is particularly useful when some tools need more CPU or memory than others.
Example nginx rule that sends export-collection to a beefier backend pool:
upstream standard { server kb-mcp-1:3000; server kb-mcp-2:3000; server kb-mcp-3:3000; }
upstream heavy { server kb-mcp-heavy:3000; }
server {
listen 80;
location /mcp {
set $pool standard;
if ($http_mcp_name = "export-collection") {
set $pool heavy;
}
proxy_pass http://$pool;
}
}Your tool code does not change. The headers are emitted automatically by v2 clients when talking to v2 servers.
Step 8: Migrate an Existing v1 Server
If you already have a v1 MCP server, the official codemod handles most of the migration:
npx @modelcontextprotocol/codemod@beta v1-to-v2 ./srcAfter the codemod, check two things manually:
Error codes — If you match on error code -32002 for missing resources, change it to -32602 (standard JSON-RPC invalid params).
Initialize handlers — Any onInitialize callbacks must move. Capabilities you previously pushed during initialize should now be returned in response to server/discover. If you were only pushing the standard capability list, you can delete the handler entirely — the SDK fills it automatically.
Run your test suite after the codemod. Most projects need under ten minutes of manual cleanup.
Step 9: Production Deployment
The stateless design makes horizontal scaling trivial. A three-instance Docker Compose stack with plain round-robin:
version: "3.9"
services:
kb-mcp-1:
build: .
environment:
NODE_ENV: production
kb-mcp-2:
build: .
environment:
NODE_ENV: production
kb-mcp-3:
build: .
environment:
NODE_ENV: production
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "80:80"upstream mcp_pool {
server kb-mcp-1:3000;
server kb-mcp-2:3000;
server kb-mcp-3:3000;
}
server {
listen 80;
location /mcp {
proxy_pass http://mcp_pool;
}
}No session store. No sticky routing directives. No coordination layer. You can take any instance down for a rolling deploy and in-flight requests either complete on the remaining instances or fail fast and retry — whichever your client is configured to do.
Testing Your Implementation
Start the server:
pnpm startIn a second terminal, launch the beta Inspector:
npx @modelcontextprotocol/inspector@beta http://localhost:3000/mcpWalk through each tool in the Inspector:
- search-docs — query
"stateless"and verify the response arrives without a session handshake visible in the network tab. - export-collection — pass
tag: "mcp"and confirm you receive ataskIdimmediately. Polltasks/getuntil the status showscompleted. - create-article — omit
confirmedand verify thatInputRequiredResultsurfaces a confirmation prompt. Retry withconfirmed: trueand check the article appears in a subsequentsearch-docscall.
Troubleshooting
Mcp-Session-Id rejection — a v1 client that sends session headers will still work; the v2 server ignores headers it does not recognise. If you see rejection errors, check whether they originate from your gateway rather than the SDK.
Tasks not found — make sure TasksExtension is instantiated before any registerTool call that references it.
MRTR loop — if the client retries without passing the user's answer, the tool returns InputRequiredResult again indefinitely. Confirm that confirmed is present in the input schema and that your client threads it back on retry.
server/discover returns empty tools list — call server.connect(transport) before registering tools, or move tool registrations above the connect call.
Conclusion
The 2026-07-28 MCP spec turns session management from your problem into a non-problem. Removing the handshake eliminated an entire category of distributed systems complexity — sticky routing, shared session stores, complex graceful shutdowns — and replaced it with plain HTTP semantics.
Your key takeaways:
- Stateless transport means any instance handles any request — standard round-robin is sufficient
server/discoverreplaces theinitializehandshake for capability negotiation_metacarries client context per-request rather than per-sessionTasksExtensionhandles long-running work without blocking HTTP connectionsInputRequiredResultimplements confirmation flows without a bespoke state machine- The official codemod covers most of the v1-to-v2 migration in minutes
The final spec ships July 28. The beta SDK is production-worthy for new servers today.
Next Steps
- Read the MCP 2026-07-28 stateless protocol guide for the complete specification overview
- Explore building an MCP client in TypeScript to understand the protocol from the client side
- Study enterprise MCP patterns for multi-tenant and multi-server deployments
- Add OpenTelemetry instead of MCP Logging (deprecated in v2) — check the official migration guide for the recommended setup