MCP 2026-07-28: no more sessions, and how to test a server with curl
The Model Context Protocol had its biggest revision since launch on 28 July 2026. Version 2026-07-28 removes sessions and the initialize handshake, so every request now stands on its own. For anyone who tests APIs, that changes MCP from something you need a client library to talk to into something you can test with curl: one HTTP POST in, one JSON answer out.
This post goes through what changed for a server and its callers, and then tests a real one. We built a small MCP server with the official TypeScript SDK v2 (@modelcontextprotocol/server 2.3.1) that wraps a product lookup on our free API, sent it requests by hand, and wrote nine tests with nothing but fetch and node:test. Every response below is what that server returned on 23 October 2026.
What changed for anyone calling an MCP server
The official changelog lists nine major changes. These are the ones you see on the wire:
| 2025-11-25 | 2026-07-28 | |
|---|---|---|
| Starting a conversation | initialize, then notifications/initialized | Nothing. Each request carries its version and capabilities in _meta |
| Sessions | Mcp-Session-Id header on every request | Removed. State a tool needs is passed as an ordinary argument |
| Finding out what a server supports | The initialize result | server/discover, which every server must implement |
| HTTP headers | MCP-Protocol-Version | MCP-Protocol-Version, plus Mcp-Method and Mcp-Name, which must match the body |
| Server-to-client notifications | A GET stream, resources/subscribe | One subscriptions/listen POST stream |
| Every result | As defined per method | Also resultType; list results add ttlMs and cacheScope |
| Server asking the client for input | sampling/createMessage, elicitation/create requests | The result says input_required; the client retries with the answers |
ping, logging/setLevel | Methods | Removed; the log level goes in each request's _meta |
| A broken SSE stream | Resumed with Last-Event-ID | Lost. The client sends the request again, with a new id |
The error codes moved too. A missing resource is now -32602 (Invalid params) instead of -32002, and the specification reserves -32020 to -32099 for itself: -32020 is HeaderMismatch, -32021 MissingRequiredClientCapability and -32022 UnsupportedProtocolVersion. If your client or your tests match error codes, update them.
The server
One tool, get-product, that looks a product up on api.testingapis.com. A tool that fails returns isError: truewith a message the model can read, rather than throwing: that's what lets an agent recover. responseMode: "json" makes every answer a single JSON body instead of an event stream, which keeps the examples short.
// A stateless MCP server (spec 2026-07-28) with one tool that reads a product from the testingapis.com free API.
import { createServer } from "node:http";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from "@modelcontextprotocol/node";
import * as z from "zod/v4";
const handler = createMcpHandler(() => {
const server = new McpServer({ name: "products", version: "1.0.0" });
server.registerTool(
"get-product",
{
description: "Look up a product by its id",
inputSchema: z.object({ id: z.number().int().positive() }),
},
async ({ id }) => {
const res = await fetch(`https://api.testingapis.com/api/products/${id}`);
if (!res.ok) return { isError: true, content: [{ type: "text", text: `No product ${id} (HTTP ${res.status})` }] };
const p = await res.json();
return { content: [{ type: "text", text: `${p.title}: $${p.price}` }] };
}
);
return server;
}, { responseMode: "json" });
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3100, "127.0.0.1", () => console.log("MCP server on http://127.0.0.1:3100/mcp"));$ npm install @modelcontextprotocol/server@2.3.1 @modelcontextprotocol/node@2.1.1 zod
$ node server.mjs
MCP server on http://127.0.0.1:3100/mcpOne request, nothing before it
Before 2026-07-28, the first thing a client did was initialize, and every later request carried the session id it got back. Now the first request can be anything. To ask a server what it supports, call server/discover:
curl http://127.0.0.1:3100/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { "name": "curl", "version": "8" }
}}}'{
"result": {
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": { "listChanged": true } },
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "private",
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "products", "version": "1.0.0" } }
},
"jsonrpc": "2.0",
"id": 1
}Three things in that request are new and required: the MCP-Protocol-Version header, the Mcp-Method header naming the method, and the _meta block with the protocol version and client capabilities. The clientInfoentry is optional but recommended, and the server names itself in every result's _meta in return.
Listing and calling a tool
tools/list takes the same headers and _meta. The result now carries ttlMs, how long a client may cache the list, and cacheScope, whether a shared cache may keep it. The input schema is JSON Schema 2020-12, generated here from the Zod schema:
{
"result": {
"tools": [
{
"name": "get-product",
"description": "Look up a product by its id",
"inputSchema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "id": { "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 } },
"required": ["id"]
}
}
],
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "private",
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "products", "version": "1.0.0" } }
},
"jsonrpc": "2.0",
"id": 2
}Calling the tool adds one more header, Mcp-Name, with the tool's name. There is no Mcp-Session-Id in the response, because there is no session:
curl -i http://127.0.0.1:3100/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: get-product' \
-d '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {
"name": "get-product", "arguments": { "id": 1 },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}}}'HTTP/1.1 200 OK
content-type: application/json
{
"result": {
"content": [{ "type": "text", "text": "Wireless Noise-Cancelling Headphones: $249.99" }],
"resultType": "complete",
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "products", "version": "1.0.0" } }
},
"jsonrpc": "2.0",
"id": 3
}The headers must agree with the body
The new headers let a gateway or load balancer route MCP traffic without reading the body. The catch is that the server checks them against the body, and refuses a request where they disagree. Leaving out MCP-Protocol-Version while the body names 2026-07-28 gets this:
{
"jsonrpc": "2.0",
"error": {
"code": -32020,
"message": "Bad Request: the request headers and body disagree: the body envelope names protocol version 2026-07-28 but the required MCP-Protocol-Version header is absent",
"data": { "mismatch": { "header": "(missing)", "body": "the body envelope names protocol version 2026-07-28 but the required MCP-Protocol-Version header is absent" } }
},
"id": 2
}Sending Mcp-Method: tools/list with a tools/call body, the kind of mistake a hand-written proxy makes, gets a 400 with the same code:
HTTP/1.1 400 Bad Request
{
"jsonrpc": "2.0",
"error": {
"code": -32020,
"message": "Bad Request: the request headers and body disagree: the body names method tools/call but the Mcp-Method header names tools/list",
"data": { "mismatch": { "header": "tools/list", "body": "the body names method tools/call but the Mcp-Method header names tools/list" } }
},
"id": 4
}A version the server doesn't speak gets a 400 that lists the versions it does:
HTTP/1.1 400 Bad Request
{
"jsonrpc": "2.0",
"error": {
"code": -32022,
"message": "Unsupported protocol version: 2027-01-01",
"data": { "supported": ["2026-07-28"], "requested": "2027-01-01" }
},
"id": 9
}Tool errors and protocol errors
Two kinds of failure look different, and tests should tell them apart. A tool that ran and failed, because the arguments were wrong or the product doesn't exist, is a normal result with isError: true: the model sees the message and can try again. A request the server can't act on at all, such as a tool that doesn't exist, is a JSON-RPC error. All three of these came back with HTTP 200:
// arguments: { "id": -1 } → HTTP 200
{ "result": { "content": [{ "type": "text", "text": "Input validation error: Invalid arguments for tool get-product: id: Too small: expected number to be >0" }],
"isError": true, "resultType": "complete" }, ... }
// arguments: { "id": 99999 } → HTTP 200
{ "result": { "content": [{ "type": "text", "text": "No product 99999 (HTTP 404)" }],
"isError": true, "resultType": "complete" }, ... }
// name: "nope" → HTTP 200
{ "jsonrpc": "2.0", "id": 8, "error": { "code": -32602, "message": "Tool nope not found" } }The tests
Because each request is self-contained, a test is one function call: no client, no connection to set up, no session to keep. This helper builds the body and the headers that must agree with it:
/** One MCP request: a JSON-RPC body, plus the headers that must agree with it. */
async function mcp(method, params = {}, { headers = {}, version = VERSION } = {}) {
const res = await fetch(URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": version,
"Mcp-Method": method,
...(params.name ? { "Mcp-Name": params.name } : {}),
...headers,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: nextId++,
method,
params: {
...params,
_meta: {
"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { name: "mcp-tests", version: "1.0.0" },
},
},
}),
});
return { status: res.status, headers: res.headers, body: await res.json() };
}The nine tests check what the specification requires, and what a real client trips over:
server/discoveranswers and names the version, and the list results carryttlMsandcacheScope.- Every tool has an object input schema and a description, which is all a model has to go on.
- No response carries
Mcp-Session-Id. A server that still sets one was upgraded halfway. - A good call works; bad arguments and a missing product are tool errors; an unknown tool is
-32602. - Mismatched headers and an unknown version are refused with 400,
-32020and-32022.
test("headers that disagree with the body are refused with 400 and HeaderMismatch (-32020)", async () => {
const { status, body } = await mcp("tools/call", { name: "get-product", arguments: { id: 1 } }, { headers: { "Mcp-Method": "tools/list" } });
assert.equal(status, 400);
assert.equal(body.error.code, -32020);
});$ npm start # in one terminal
$ npm test # in another✔ server/discover names the versions and capabilities (245.0492ms)
✔ tools/list is cacheable and every tool has an input schema (18.7131ms)
✔ no session: the server never hands out Mcp-Session-Id (8.75ms)
✔ tools/call returns the product (222.3407ms)
✔ a bad argument is a tool error the model can read, not a protocol error (25.0398ms)
✔ a product that doesn't exist says so (57.9465ms)
✔ an unknown tool is Invalid params (-32602) (11.081ms)
✔ headers that disagree with the body are refused with 400 and HeaderMismatch (-32020) (5.6487ms)
✔ an unsupported version is refused with UnsupportedProtocolVersion (-32022) and the supported list (6.6507ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0Download mcp-server-examples.zip to run the server and tests yourself; the README says how. Point MCP_URL at your own server and the protocol tests apply to it unchanged.
Old clients still work
Not every client will move at once, and the SDK doesn't make them. The same server answered a 2025-style initialize with protocol version 2025-11-25, so the two generations can share one endpoint while clients catch up:
curl http://127.0.0.1:3100/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc": "2.0", "id": 10, "method": "initialize", "params": {
"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "old", "version": "1" } }}'
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"products","version":"1.0.0"}},"jsonrpc":"2.0","id":10}Deprecated, and what to use instead
2026-07-28 also introduces a deprecation policy: a deprecated feature keeps working for at least twelve months before it's removed. Four things entered that state in this release:
- Roots: pass directories or files as tool arguments, resource URIs or server configuration.
- Sampling: call the language model's provider directly from the server.
- Logging: write to stderr on stdio servers, or use OpenTelemetry. Trace context now travels in
_metaastraceparent,tracestateandbaggage. - Dynamic Client Registration (RFC 7591) for OAuth: use Client ID Metadata Documents. Clients must also check the
issparameter in authorization responses (RFC 9207).
The old HTTP+SSE transport, deprecated since 2025-03-26, is now formally on the same path. Use Streamable HTTP.
Keeping an eye on it
An MCP server is now an HTTP endpoint you can call with a fixed request and check the answer of, so the tools you already use for APIs work on it: an uptime monitor that POSTs a tools/list and checks for resultTypewill tell you when a deploy breaks your tools for every agent that uses them. To read a server's responses, the JSON formatter and JSONPath tester help, the JSON Schema validator checks tool arguments against an inputSchema, and the cURL converter turns the requests above into code for your language.
We now run one ourselves, built the same way: add testingapis.com/mcp to Claude, Cursor or VS Code and your assistant can use the tools above directly.
Sources: the MCP 2026-07-28 changelog and specification, and the TypeScript SDK v2 documentation at ts.sdk.modelcontextprotocol.io.