What we are building
The newest MCP spec revision removes the thing most MCP servers quietly depend on: the session. There is no initialize handshake, no Mcp-Session-Id header, and a server can no longer open a request back to the client in the middle of a tool call. If you wrote a server against the 2025 revisions that asks the user "are you sure?" by sending elicitation/create over an open stream, that code path is gone.
What replaces it is a pattern called Multi Round-Trip Requests, or MRTR. The server answers tools/call with a result of type input_required, carrying the question and an opaque requestState string. The client asks the user, then retries the same call with the answer and the state echoed back. Two independent HTTP requests, and no server memory linking them.
We will build a small server called release-ops with one tool, rollback_release. The first call never touches anything: it returns a confirmation form. Only a retry that carries a valid, signed, unexpired answer actually performs the rollback. Then we write a 60-line client that plays both sides and proves the failure paths work. The design choice worth your attention is that requestState passes through the client, so it is attacker-controlled input, and the spec says so in as many words. Most of this tutorial is about doing that part right.
Prerequisites
You need Node 20 or newer (I ran this on Node 22) and a terminal. There are no npm dependencies: the server uses node:http and node:crypto, the client uses the built-in fetch. No API keys, no accounts, no paid plan.
You should be comfortable reading modern JavaScript and have a rough idea of what JSON-RPC looks like. You do not need prior MCP experience, but it helps to know that an MCP server exposes tools that an LLM client can call.
One caveat up front. I am writing against the published 2026-07-28 text, not an SDK. The wire format is small enough to implement by hand, and that is the fastest way to see which parts of an older server stop working. If you use an official SDK, check its changelog for this revision before assuming it handles requestState for you.
Setup
Create a folder and two empty files.
mkdir release-ops && cd release-ops
touch server.mjs client.mjs
node --version # v20 or newer
We will build server.mjs in pieces, appending each block to the same file in order. The finished server is under 180 lines. The client comes after, and the last step ends with a run.
Set a signing secret if you want state to survive a restart. If you do not, the server generates a random one at boot, which is fine for this tutorial and wrong for production, for reasons I will come back to.
export STATE_SECRET="$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')"
Step 1: Constants, the tool definition, and three helpers
Start with the facts the server needs to state about itself. Paste this at the top of server.mjs.
// server.mjs - a stateless MCP server (spec 2026-07-28), zero dependencies.
import http from "node:http";
import crypto from "node:crypto";
const VERSION = "2026-07-28";
const SUPPORTED = [VERSION];
const SECRET = process.env.STATE_SECRET ?? crypto.randomBytes(32).toString("hex");
const TTL_MS = 5 * 60 * 1000;
const PRINCIPAL = "demo-user"; // in production: the authenticated subject
const SERVER_INFO = {
"io.modelcontextprotocol/serverInfo": { name: "release-ops", version: "1.0.0" },
};
const TOOLS = [
{
name: "rollback_release",
description: "Roll a service back to a previous release. Asks the user to confirm first.",
inputSchema: {
type: "object",
properties: {
service: { type: "string", "x-mcp-header": "Service" },
version: { type: "string" },
},
required: ["service", "version"],
},
},
];
Two details here are not obvious. The x-mcp-header annotation on service tells clients to mirror that argument into an Mcp-Param-Service HTTP header, so a load balancer or gateway can route or rate-limit on the service name without parsing the body. It is optional for servers, mandatory for clients to honor, and the spec warns against using it for anything sensitive because headers are visible to intermediaries. A service name is fine. A token is not.
The SERVER_INFO object lives under a namespaced _meta key. The spec says servers SHOULD identify themselves in every result this way, replacing the serverInfo that used to arrive once during initialize. Since there is no handshake anymore, identity travels with each response.
Now the three helpers that the handler will lean on. Append this below the first block.
const rpcError = (id, code, message, data) => ({
jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) },
});
function send(res, status, body) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
// Mcp-Name and Mcp-Param-* may arrive as =?base64?...?= when not plain ASCII.
function headerValue(v) {
if (v === undefined) return undefined;
const m = /^=\?base64\?(.*)\?=$/.exec(v);
return m ? Buffer.from(m[1], "base64").toString("utf8") : v;
}
The headerValue function handles a corner of the transport that is easy to miss. If a tool name or parameter value contains non-ASCII characters or leading whitespace, clients must encode the header as =?base64?...?=, and servers must decode it before comparing against the body. Skip this and your server rejects legitimate requests for tools with unusual names.
Step 2: Make requestState safe
Here is the part that matters most. The spec's rule is blunt: a server MUST treat requestState as attacker-controlled input, and if it influences authorization or business logic, MUST protect its integrity with an HMAC or AEAD and reject anything that fails verification. It also lists what a well-built state should bind to: the authenticated principal, a short expiry, and an identifier of the originating request.
We will do exactly that. State is a base64url JSON payload plus an HMAC-SHA256 tag. Append this block.
const digest = (method, args) =>
crypto.createHash("sha256").update(method + JSON.stringify(args)).digest("hex");
function sealState(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const mac = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
return `${body}.${mac}`;
}
function openState(token, req) {
const [body, mac] = String(token).split(".");
if (!body || !mac) return null;
const expect = crypto.createHmac("sha256", SECRET).update(body).digest();
const got = Buffer.from(mac, "base64url");
if (got.length !== expect.length || !crypto.timingSafeEqual(got, expect)) return null;
const p = JSON.parse(Buffer.from(body, "base64url").toString());
if (p.exp < Date.now()) return null; // expired
if (p.sub !== PRINCIPAL) return null; // someone else's state
if (p.req !== req) return null; // different tool or arguments
return p;
}
openState is four independent gates. The MAC check uses timingSafeEqual so verification time does not leak the tag. The expiry bounds the replay window to five minutes. The principal check stops one user's confirmation from being presented by another. The request digest, built from the method name plus the tool arguments, stops a confirmation for "roll back checkout to v1.41.2" from being reused to authorize "roll back payments to v0.1.0".
One limit deserves to be stated plainly, because the spec states it too: these measures bound replay and prevent cross-user and cross-request reuse, but they do not make a state single-use. Within its five minutes, the same valid state can be sent twice. For a rollback that is probably tolerable. For something like a one-time payout it is not, and you would need server-side bookkeeping, a consumed-nonce table for instance. That is a legitimate reintroduction of storage, scoped to the one case that needs it.
Step 3: The tool, as a two-pass function
Now the logic of rollback_release. It has three outcomes: ask, refuse, or act. Append this.
const text = (t, extra = {}) => ({
resultType: "complete", content: [{ type: "text", text: t }], ...extra,
});
function askToConfirm(args, reqDigest) {
return {
resultType: "input_required",
inputRequests: {
confirm: {
method: "elicitation/create",
params: {
mode: "form",
message: `Roll ${args.service} back to ${args.version}? This restarts every instance.`,
requestedSchema: {
type: "object",
properties: { confirm: { type: "boolean" }, reason: { type: "string" } },
required: ["confirm"],
},
},
},
},
requestState: sealState({ sub: PRINCIPAL, exp: Date.now() + TTL_MS, req: reqDigest }),
};
}
function rollbackRelease(params, caps) {
const args = params.arguments ?? {};
const reqDigest = digest("tools/call:rollback_release", args);
if (params.requestState === undefined) {
if (!caps.elicitation) {
return text("This tool needs a client that supports elicitation.", { isError: true });
}
return askToConfirm(args, reqDigest); // first pass: decide nothing, change nothing
}
if (!openState(params.requestState, reqDigest)) {
return text("Confirmation expired or was tampered with. Start again.", { isError: true });
}
const answer = params.inputResponses?.confirm;
if (!answer) return askToConfirm(args, reqDigest); // client skipped the question: ask again
if (answer.action !== "accept" || answer.content?.confirm !== true) {
return text("Rollback cancelled. Nothing changed.");
}
return text(`Rolled ${args.service} back to ${args.version}. Reason: ${answer.content.reason ?? "(none given)"}`);
}
Trace the first pass. There is no requestState on the request, so the function checks that the client declared the elicitation capability. This matters: the spec forbids a server from sending an input request the client has not said it supports. A client that cannot show a form gets a clear tool error instead of an input_required it can never satisfy. If the capability is present, we return the form and a sealed state, having changed nothing.
On the retry, the order of checks is deliberate. We verify the state before we read the answer. Only then do we look at inputResponses.confirm. If the client sent a state but no answer, we ask again rather than erroring, which follows the spec's guidance to re-request missing information. And a decline is not an error: action other than accept, or confirm not exactly true, returns an ordinary result saying nothing changed.
Notice what the function does not do. It never reads a session, a map, or a database. Everything it knows about the first request it learns from the signed string. That is the whole point: any instance behind your load balancer can serve the retry.
Step 4: The HTTP handler
The last server piece is the transport. It enforces the header rules, then dispatches. Append the first half.
async function handle(req, res) {
const origin = req.headers.origin;
if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
return send(res, 403, rpcError(null, -32600, "Forbidden origin"));
}
if (req.method !== "POST") { res.writeHead(405, { allow: "POST" }); return res.end(); }
let raw = ""; for await (const c of req) raw += c;
let msg; try { msg = JSON.parse(raw); } catch { return send(res, 400, rpcError(null, -32700, "Parse error")); }
const { id, method, params = {} } = msg;
const meta = params._meta ?? {};
// 1. Version: header and body must agree, and we must speak it.
const hVersion = req.headers["mcp-protocol-version"];
const bVersion = meta["io.modelcontextprotocol/protocolVersion"];
if (!hVersion || hVersion !== bVersion) {
return send(res, 400, rpcError(id, -32020, `Header mismatch: MCP-Protocol-Version '${hVersion}' vs body '${bVersion}'`));
}
if (!SUPPORTED.includes(bVersion)) {
return send(res, 400, rpcError(id, -32022, "Unsupported protocol version", { supported: SUPPORTED, requested: bVersion }));
}
// 2. Mirrored headers must match the body.
if (req.headers["mcp-method"] !== method) {
return send(res, 400, rpcError(id, -32020, `Header mismatch: Mcp-Method '${req.headers["mcp-method"]}' vs body '${method}'`));
}
if (method === "tools/call") {
if (headerValue(req.headers["mcp-name"]) !== params.name) {
return send(res, 400, rpcError(id, -32020, "Header mismatch: Mcp-Name"));
}
for (const tool of TOOLS.filter((t) => t.name === params.name)) {
for (const [prop, def] of Object.entries(tool.inputSchema.properties)) {
const h = def["x-mcp-header"];
const val = params.arguments?.[prop];
if (h && val !== undefined && headerValue(req.headers[`mcp-param-${h.toLowerCase()}`]) !== String(val)) {
return send(res, 400, rpcError(id, -32020, `Header mismatch: Mcp-Param-${h}`));
}
}
}
}
Three things in there come straight from the transport spec. The Origin check returns 403 for a present but non-local origin, which is the DNS rebinding defense, and the server binds to 127.0.0.1 only. The version check compares the MCP-Protocol-Version header with the _meta field in the body and answers a mismatch with HTTP 400 and error code -32020. An unsupported version gets 400 and -32022, with a data.supported list so the client can retry with a version you do speak. Those two codes are new in this revision, carved out of a range the spec now reserves for itself.
The header and body comparison is a security feature, not pedantry. If a gateway routes on Mcp-Name and your server executes on params.name, an attacker who can forge one but not the other gets routed past policy. Rejecting any disagreement closes that gap. Append the second half, which is the dispatcher and the listener.
// 3. Dispatch.
const caps = meta["io.modelcontextprotocol/clientCapabilities"] ?? {};
switch (method) {
case "server/discover":
return send(res, 200, { jsonrpc: "2.0", id, result: {
resultType: "complete", supportedVersions: SUPPORTED, capabilities: { tools: {} },
_meta: SERVER_INFO, instructions: "Release operations. rollback_release asks for confirmation.",
ttlMs: 3_600_000, cacheScope: "public",
}});
case "tools/list":
return send(res, 200, { jsonrpc: "2.0", id, result: {
resultType: "complete", tools: TOOLS, _meta: SERVER_INFO, ttlMs: 300_000, cacheScope: "public",
}});
case "tools/call":
if (params.name !== "rollback_release") return send(res, 200, rpcError(id, -32602, `Unknown tool: ${params.name}`));
return send(res, 200, { jsonrpc: "2.0", id, result: { ...rollbackRelease(params, caps), _meta: SERVER_INFO } });
default:
return send(res, 404, rpcError(id, -32601, `Method not found: ${method}`));
}
}
http.createServer((req, res) => handle(req, res).catch((e) => send(res, 500, rpcError(null, -32603, String(e)))))
.listen(3000, "127.0.0.1", () => console.log("MCP on http://127.0.0.1:3000/mcp"));
server/discover is mandatory for servers and optional for clients. It returns supported versions, capabilities, and instructions, and carries ttlMs and cacheScope so a client can cache it. tools/list carries the same two fields, which the spec now requires on every list-style result. The list is also a constant array in a fixed order: the spec asks for deterministic ordering so clients can cache and so LLM prompt caches keep hitting.
Start it. In one terminal:
node server.mjs
# MCP on http://127.0.0.1:3000/mcp
Step 5: A client that plays both sides
A real client is an LLM host with a UI. Ours is a script standing in for one. First the request builder, which attaches the per-request metadata that replaces the handshake.
// client.mjs - a tiny client that speaks the 2026-07-28 wire format.
const ENDPOINT = "http://127.0.0.1:3000/mcp";
let nextId = 1;
async function rpc(method, params = {}, { caps = {}, headers = {} } = {}) {
const body = {
jsonrpc: "2.0", id: nextId++, method,
params: { ...params, _meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "tiny-client", version: "0.1.0" },
"io.modelcontextprotocol/clientCapabilities": caps,
}},
};
const h = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"mcp-protocol-version": "2026-07-28",
"mcp-method": method,
};
if (method === "tools/call") h["mcp-name"] = params.name;
Object.assign(h, headers); // lets the tests below forge bad headers
const res = await fetch(ENDPOINT, { method: "POST", headers: h, body: JSON.stringify(body) });
return { status: res.status, json: await res.json() };
}
Every request carries version, identity, and capabilities in _meta, plus the mirrored headers.
Now the happy path. Append this.
const caps = { elicitation: { form: {} } };
const args = { service: "checkout", version: "v1.41.2" };
const svc = { "mcp-param-service": "checkout" };
console.log("--- discover");
console.log((await rpc("server/discover")).json.result.supportedVersions);
console.log("--- call 1: the server asks");
const r1 = (await rpc("tools/call", { name: "rollback_release", arguments: args }, { caps, headers: svc })).json.result;
console.log(r1.resultType, "|", r1.inputRequests.confirm.params.message);
console.log("--- call 2: the user says yes");
const r2 = (await rpc("tools/call", {
name: "rollback_release", arguments: args, requestState: r1.requestState,
inputResponses: { confirm: { action: "accept", content: { confirm: true, reason: "p99 regression" } } },
}, { caps, headers: svc })).json.result;
console.log(r2.resultType, "|", r2.content[0].text);
Note the retry: it is a new JSON-RPC id (the builder increments it), it repeats the original arguments, and it adds inputResponses and the state exactly as received. The spec requires that the client not inspect or modify requestState. Our client treats it as a black box, which is why a tampered state fails on the server and nowhere else.
Finally, the hostile cases.
console.log("--- replay the same state against different arguments");
const r3 = (await rpc("tools/call", {
name: "rollback_release", arguments: { ...args, version: "v0.1.0" }, requestState: r1.requestState,
inputResponses: { confirm: { action: "accept", content: { confirm: true } } },
}, { caps, headers: svc })).json.result;
console.log(r3.isError, "|", r3.content[0].text);
console.log("--- forged Mcp-Name header");
const r4 = await rpc("tools/call", { name: "rollback_release", arguments: args },
{ caps, headers: { "mcp-name": "something_else", ...svc } });
console.log(r4.status, r4.json.error.code);
console.log("--- unsupported version");
const r5 = await fetch(ENDPOINT, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json, text/event-stream",
"mcp-protocol-version": "1900-01-01", "mcp-method": "server/discover" },
body: JSON.stringify({ jsonrpc: "2.0", id: 9, method: "server/discover",
params: { _meta: { "io.modelcontextprotocol/protocolVersion": "1900-01-01" } } }),
});
console.log(r5.status, JSON.stringify((await r5.json()).error.data));
Verify it works
With the server running in one terminal, run the client in another:
node client.mjs
You should see exactly this:
--- discover
[ '2026-07-28' ]
--- call 1: the server asks
input_required | Roll checkout back to v1.41.2? This restarts every instance.
--- call 2: the user says yes
complete | Rolled checkout back to v1.41.2. Reason: p99 regression
--- replay the same state against different arguments
true | Confirmation expired or was tampered with. Start again.
--- forged Mcp-Name header
400 -32020
--- unsupported version
400 {"supported":["2026-07-28"],"requested":"1900-01-01"}
I ran this server and client on Node 22 before writing this post, and that is the output they produced. The contract is simple: the second call succeeds only because it carries state minted by the first, and each of the three hostile requests fails for a distinct reason. If all six sections print as above, the build works.
When it breaks
State verifies on one machine and fails on another. This is the production trap. We generate SECRET randomly at boot when STATE_SECRET is unset, so each instance signs with a different key, and a retry that lands on a different instance gets "tampered with". Every instance behind the load balancer must share the same secret, and you need a plan for rotating it. Accept the previous key for one TTL window during rotation.
Every request returns 400 with -32020. Your client is not sending Mcp-Method, or the header differs from the body, or MCP-Protocol-Version is missing. The error message names which header. Remember header names are case-insensitive but values are not.
The tool says it needs elicitation. Your client did not put elicitation in io.modelcontextprotocol/clientCapabilities. This is the server doing what the spec requires. Fix the client's declared capabilities, or have the tool fall back to refusing the risky action.
Confirmations expire while the user is thinking. Five minutes is short if the question lands in a notification. Raise TTL_MS, but weigh the longer replay window. The retry after expiry returns the "start again" error, and the model can simply call the tool again for a fresh question.
An older client gets a 400. A client built for the 2025 revisions sends initialize and sends Mcp-Session-Id, and our server rejects it, because the required headers and _meta are missing. That is the spec's compatibility matrix talking: a legacy client cannot talk to a modern-only server. If you must serve both, a dual-era server selects behavior from how the client opens.
Where to take it next
Easiest: add a second tool that reuses askToConfirm with a different message. The per-tool digest in the state keeps the two tools from authorizing each other.
Medium: replace the constant PRINCIPAL with the subject of a validated bearer token, so the principal gate actually discriminates between users. Right now it is a placeholder, and the principal check does nothing until you do this.
Harder: build the consumed-nonce table for a tool that must be single-use, and keep it as the only stateful corner of an otherwise stateless server.
Where does your own server lean on a session without saying so? Each place is now either a handle in a tool argument or a signed string in requestState, and choosing between them is a design decision you can no longer defer.

