Migrating Between Protocol Eras: 2025-11-25 to 2026-07-28
The 2026-07-28 revision made MCP stateless: no initialize handshake, server/discover instead of a negotiated session, and per-request metadata that every request must carry. If you maintain a client that must talk to both a 2025-11-25 server and a 2026-07-28 server, this page walks the whole difference against one echo server — positive paths first, then one-variable negatives for each rule.
What this page is. Every command below was run against the minimal server embedded on this page — Python standard library only, synthetic data, loopback only, one
echotool. It is a teaching fixture, not a protocol implementation: it enforces the request shapes the examples need (mirrored headers,params._meta,input_requiredretries, elicitation capability checking) and nothing else — no auth, sessions, streams, caching orx-mcp-headerparameter mirroring. The normative text lives in the Streamable HTTP transport, MRTR and tools pages.
What every modern request must carry
The transport spec mirrors selected body fields into HTTP headers, and the tools spec requires _meta on every request. A conforming 2026-07-28 request therefore carries:
Accept: application/json, text/event-streamon every POST.MCP-Protocol-Version: 2026-07-28— and the header must equalparams._meta.io.modelcontextprotocol/protocolVersion.Mcp-Method: <method>on every request, mirroring the bodymethod.Mcp-Nameontools/call,resources/readandprompts/get— it mirrorsparams.namefortools/callandprompts/get, butparams.uriforresources/read.params._metawithio.modelcontextprotocol/protocolVersion,io.modelcontextprotocol/clientInfoandio.modelcontextprotocol/clientCapabilities. If a request will make the server ask the user a form,clientCapabilities.elicitation.formmust be declared too.
The server answers violations with the schema’s own error shapes, each at its own layer:
| Violation | HTTP | JSON-RPC |
|---|---|---|
Accept missing one media type | 406 | plain HTTP rejection, before JSON-RPC processing |
| body is not valid JSON | 400 | -32700 Parse error |
| top level not a JSON-RPC request object | 400 | -32600 Invalid Request |
params / _meta / arguments / inputResponses wrong type or missing required fields | 400 | -32602 Invalid params |
a standard header (MCP-Protocol-Version, Mcp-Method, Mcp-Name) missing, or not matching the body | 400 | -32020 HeaderMismatch |
header and _meta agree, but on a version the server does not implement | 400 | -32022 UnsupportedProtocolVersion with error.data.supported / error.data.requested |
| the server needs an elicitation form the client did not declare | 400 | -32021 MissingRequiredClientCapability with error.data.requiredCapabilities |
unknown RPC method — initialize included | 404 | -32601 Method not found |
Success results follow the schema too: DiscoverResult requires resultType, supportedVersions, capabilities, ttlMs and cacheScope; ListToolsResult requires tools, resultType, ttlMs and cacheScope; CallToolResult requires resultType and content (isError marks a tool-level failure). A request that needs more input answers resultType: "input_required" for a multi round-trip continuation.
The server
Save the following as era_stub.py. It answers /mcp on a port you choose and takes one argument: legacy behaves like a 2025-11-25 server, modern like a 2026-07-28 server, input-required like modern except that tools/call without the text parameter answers input_required and expects the spec-shaped retry.
"""Stdlib HTTP stub for the Inspector checks: http://127.0.0.1:PORT/mcp
stub_server.py PORT MODE [--log FILE]
MODE:
legacy plain 2025-11-25 server: initialize, tools/list (one tool), tools/call; no server/discover
bad-schema like legacy, but the tool schema has a bare boolean, a type array and an empty schema
warn-schema like legacy, but the tool schema has only a type array (a portability warning)
no-capability like legacy, but initialize does not advertise capabilities.tools
auth 401 with WWW-Authenticate on every request
forbidden 403 on every request
not-found 404 on every request
error-500 500 on every request
non-json 200 with an HTML body
modern plain 2026-07-28 stateless server: server/discover, tools/list, tools/call;
no initialize (404 + -32601). Enforces the request rules the era requires:
Accept content types, mirrored Mcp-Method/Mcp-Name/MCP-Protocol-Version headers
(HeaderMismatch -32020), a supported protocol version (-32022 with
data.supported/requested), the params._meta fields, and object types everywhere,
answering ParseError/InvalidRequest/InvalidParams for malformed bodies.
input-required like modern, but tools/call without the text parameter answers an
InputRequiredResult (elicitation form). Only clients that declare
elicitation form support get the form (MissingRequiredClientCapability -32021
otherwise); retries carry inputResponses plus the returned requestState.
--log FILE records the JSON-RPC method of every POST, one per line, in arrival order.
"""
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MODES = ("legacy", "bad-schema", "warn-schema", "no-capability", "auth", "forbidden", "not-found", "error-500", "non-json",
"modern", "input-required")
MODERN_MODES = ("modern", "input-required")
ECHO = {"name": "echo", "description": "Echo text.",
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}
BAD = {"name": "flag", "description": "Tool with schemas that are hard to port.",
"inputSchema": {"type": "object",
"properties": {"x": True, "y": {"type": ["string", "null"]}, "z": {}}}}
MAYBE = {"name": "maybe", "description": "Tool with a nullable field.",
"inputSchema": {"type": "object", "properties": {"note": {"type": ["string", "null"]}}}}
TOOLS = {"legacy": ECHO, "bad-schema": BAD, "warn-schema": MAYBE}
VERSION = "2026-07-28"
VKEY = "io.modelcontextprotocol/protocolVersion"
META_PROTO = {"io.modelcontextprotocol/protocolVersion": VERSION}
def parse_args(argv):
"""Return (port, mode, log_path) or exit with status 2."""
log = None
if len(argv) == 4 and argv[2] == "--log":
log = argv[3]
argv = argv[:2]
if len(argv) == 2 and argv[0].isdigit() and argv[1] in MODES:
return int(argv[0]), argv[1], log
sys.stderr.write(__doc__)
sys.exit(2)
PORT, MODE, LOG_PATH = parse_args(sys.argv[1:])
if LOG_PATH:
open(LOG_PATH, "w").close()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
"""Silence the default access log."""
def reply(self, status, body, content_type="application/json", headers=()):
data = body.encode()
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
for name, value in headers:
self.send_header(name, value)
self.end_headers()
self.wfile.write(data)
def rpc_error(self, status, code, msg, data=None, request_id=None):
error = {"code": code, "message": msg}
if data is not None:
error["data"] = data
return self.reply(status, json.dumps({"jsonrpc": "2.0", "id": request_id, "error": error}))
def reply_modern(self, message, parse_ok, method, request_id):
"""Answer as a 2026-07-28 stateless server.
Validation order: Accept content types, JSON body framing, then the
era's request rules -- MCP-Protocol-Version header vs params._meta
(HeaderMismatch -32020), supported version (-32022 with data),
params._meta fields and object types (Invalid params -32602),
Mcp-Method/Mcp-Name mirrors (-32020), then 404 + -32601 for unknown
methods, initialize included.
"""
accept = self.headers.get("Accept", "")
if "application/json" not in accept or "text/event-stream" not in accept:
return self.reply(406, json.dumps({"error": "not acceptable",
"detail": "POST requires Accept: application/json, text/event-stream"}))
if parse_ok is False:
return self.rpc_error(400, -32700, "Parse error: request body is not valid JSON", request_id=request_id)
if not isinstance(message, dict):
return self.rpc_error(400, -32600, "Invalid Request: body must be a single JSON-RPC request object",
request_id=request_id)
if not isinstance(method, str) or not method:
return self.rpc_error(400, -32600, "Invalid Request: method must be a non-empty string", request_id=request_id)
params = message.get("params", {})
if not isinstance(params, dict):
return self.rpc_error(400, -32602, "Invalid params: params must be an object", request_id=request_id)
header_version = self.headers.get("MCP-Protocol-Version")
if not header_version:
return self.rpc_error(400, -32020, "Header mismatch: MCP-Protocol-Version header is required",
request_id=request_id)
meta = params.get("_meta")
if not isinstance(meta, dict):
return self.rpc_error(400, -32602, "Invalid params: params._meta is required and must be an object",
request_id=request_id)
body_version = meta.get(VKEY)
if not isinstance(body_version, str):
return self.rpc_error(400, -32602,
f"Invalid params: params._meta.{VKEY} is required and must be a string",
request_id=request_id)
if header_version != body_version:
return self.rpc_error(400, -32020,
"Header mismatch: MCP-Protocol-Version header does not match params._meta",
request_id=request_id)
if header_version != VERSION:
return self.rpc_error(400, -32022, "Unsupported protocol version",
data={"supported": [VERSION], "requested": header_version}, request_id=request_id)
client_info = meta.get("io.modelcontextprotocol/clientInfo")
if not isinstance(client_info, dict):
return self.rpc_error(400, -32602,
"Invalid params: params._meta.io.modelcontextprotocol/clientInfo is required "
"and must be an object", request_id=request_id)
client_caps = meta.get("io.modelcontextprotocol/clientCapabilities")
if not isinstance(client_caps, dict):
return self.rpc_error(400, -32602,
"Invalid params: params._meta.io.modelcontextprotocol/clientCapabilities is required "
"and must be an object", request_id=request_id)
header_method = self.headers.get("Mcp-Method")
if not header_method or header_method != method:
return self.rpc_error(400, -32020,
"Header mismatch: Mcp-Method header is required and must match the body method",
request_id=request_id)
if method == "tools/call":
header_name = self.headers.get("Mcp-Name")
if not header_name or header_name != params.get("name"):
return self.rpc_error(400, -32020,
"Header mismatch: Mcp-Name header is required and must match params.name",
request_id=request_id)
if method == "initialize" or method not in ("server/discover", "tools/list", "tools/call"):
hint = ("Method not found. This server implements the 2026-07-28 stateless protocol; "
"send server/discover instead of initialize.") if method == "initialize" else "Method not found"
return self.rpc_error(404, -32601, hint, request_id=request_id)
if method == "server/discover":
result = {"resultType": "complete", "supportedVersions": [VERSION],
"capabilities": {"tools": {}}, "ttlMs": 0, "cacheScope": "private",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "stub-modern", "version": "0"}},
"instructions": "Echo server for the protocol-era examples."}
elif method == "tools/list":
result = {"tools": [ECHO], "resultType": "complete", "ttlMs": 0, "cacheScope": "private",
"_meta": dict(META_PROTO)}
else: # tools/call
name = params.get("name")
if not isinstance(name, str) or not name:
return self.rpc_error(400, -32602, "Invalid params: params.name must be a non-empty string",
request_id=request_id)
if name != "echo":
return self.rpc_error(400, -32602, f"Invalid params: unknown tool {name!r}", request_id=request_id)
arguments = params.get("arguments", {})
if not isinstance(arguments, dict):
return self.rpc_error(400, -32602, "Invalid params: arguments must be an object",
request_id=request_id)
text = arguments.get("text")
if text is not None and not isinstance(text, str):
return self.rpc_error(400, -32602, "Invalid params: arguments.text must be a string",
request_id=request_id)
if text is None and MODE != "input-required":
return self.rpc_error(400, -32602, "Invalid params: arguments.text is required",
request_id=request_id)
if text is None:
result = self.reply_input_required(params, client_caps, request_id)
if result is None:
return None
else:
result = {"resultType": "complete", "content": [{"type": "text", "text": text}],
"isError": False, "_meta": dict(META_PROTO)}
self.reply(200, json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}))
def require_elicitation(self, client_caps, request_id):
"""Reply with MissingRequiredClientCapability unless the client declared form support.
Returns True when an error reply was sent, False when the form may be sent.
"""
elicitation = client_caps.get("elicitation") if isinstance(client_caps, dict) else None
if isinstance(elicitation, dict) and isinstance(elicitation.get("form"), dict):
return False
self.rpc_error(400, -32021, "Server requires the elicitation capability for this request",
data={"requiredCapabilities": {"elicitation": {"form": {}}}}, request_id=request_id)
return True
def reply_input_required(self, params, client_caps, request_id):
"""Handle the input-required continuation for a missing text argument.
Returns the result dict, or None after an error reply was sent.
"""
responses = params.get("inputResponses")
state = params.get("requestState")
if responses is not None and not isinstance(responses, dict):
self.rpc_error(400, -32602, "Invalid params: inputResponses must be an object", request_id=request_id)
return None
if state is not None and not isinstance(state, str):
self.rpc_error(400, -32602, "Invalid params: requestState must be a string", request_id=request_id)
return None
if not responses and state is None:
# First attempt: ask for the missing argument with an elicitation form.
if self.require_elicitation(client_caps, request_id):
return None
return self.input_required_result()
if state != "stub-1":
self.rpc_error(400, -32602,
"Invalid params: retry must carry the requestState returned with input_required",
request_id=request_id)
return None
if not responses:
# Retry carried the state but no answers: ask again, per MRTR error handling.
if self.require_elicitation(client_caps, request_id):
return None
return self.input_required_result()
entry = responses.get("text")
if not isinstance(entry, dict):
self.rpc_error(400, -32602, "Invalid params: inputResponses.text must be an ElicitResult object",
request_id=request_id)
return None
action = entry.get("action")
if action == "accept":
content = entry.get("content")
if not isinstance(content, dict) or not isinstance(content.get("text"), str):
self.rpc_error(400, -32602,
"Invalid params: inputResponses.text.content.text must be a string",
request_id=request_id)
return None
return {"resultType": "complete", "content": [{"type": "text", "text": content["text"]}],
"isError": False, "_meta": dict(META_PROTO)}
if action in ("decline", "cancel"):
# Legal user action: the tool is simply not executed. Business outcome,
# not a protocol error (isError marks the tool-level failure).
verb = "declined" if action == "decline" else "cancelled"
return {"resultType": "complete", "isError": True,
"content": [{"type": "text",
"text": f"echo not executed: user {verb} the elicitation form"}],
"_meta": dict(META_PROTO)}
self.rpc_error(400, -32602,
"Invalid params: inputResponses.text.action must be accept, decline or cancel",
request_id=request_id)
return None
@staticmethod
def input_required_result():
"""Return the InputRequiredResult for the missing text argument."""
return {"resultType": "input_required", "requestState": "stub-1",
"inputRequests": {"text": {"method": "elicitation/create",
"params": {"mode": "form",
"message": "Provide the text to echo",
"requestedSchema": {"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]}}}}}
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length)
parse_ok = True
message = None
try:
message = json.loads(raw)
except ValueError:
parse_ok = False
if parse_ok and not isinstance(message, dict):
parse_ok = "shape"
method = message.get("method") if isinstance(message, dict) else None
request_id = message.get("id") if isinstance(message, dict) else None
if LOG_PATH:
with open(LOG_PATH, "a") as log:
log.write(f"{method}\n")
if MODE == "auth":
return self.reply(401, '{"error":"unauthorized"}', headers=[("WWW-Authenticate", 'Bearer realm="mcp"')])
if MODE == "forbidden":
return self.reply(403, '{"error":"forbidden"}')
if MODE == "not-found":
return self.reply(404, "not found", "text/plain")
if MODE == "error-500":
return self.reply(500, "boom", "text/plain")
if MODE == "non-json":
return self.reply(200, "<html><body>Sign in</body></html>", "text/html")
if MODE in MODERN_MODES:
return self.reply_modern(message, parse_ok, method, request_id)
if parse_ok is not True:
code, msg = (-32700, "Parse error: request body is not valid JSON") if parse_ok is False else \
(-32600, "Invalid Request: body must be a single JSON-RPC request object")
return self.rpc_error(400, code, msg)
if request_id is None:
return self.reply(202, "")
if method == "initialize":
capabilities = {} if MODE == "no-capability" else {"tools": {}}
result = {"protocolVersion": "2025-11-25", "capabilities": capabilities,
"serverInfo": {"name": "stub", "version": "0"}}
elif method == "tools/list":
result = {"tools": [TOOLS.get(MODE, ECHO)]}
elif method == "tools/call":
result = {"content": [{"type": "text", "text": "hi"}], "isError": False}
else:
error = {"code": -32601, "message": "Method not found"}
return self.reply(200, json.dumps({"jsonrpc": "2.0", "id": request_id, "error": error}))
self.reply(200, json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}))
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()Start the three servers
Run this in your own terminal (bash or zsh). The trap kills only the three $! PIDs started by this snippet, so it cannot touch any background jobs you already have; it fires when you leave the shell (exit or Ctrl-D).
python3 era_stub.py 8811 modern & SP1=$!
python3 era_stub.py 8812 input-required & SP2=$!
python3 era_stub.py 8813 legacy & SP3=$!
trap 'kill $SP1 $SP2 $SP3 2>/dev/null' EXIT
sleep 1The one variable the requests reuse
Every request below inlines all of its headers, so each command is copy-paste safe in bash and zsh alike — no unquoted variable relies on word splitting. The only variable is $META, the params._meta object, and it is always interpolated inside a double-quoted JSON string, which both shells treat identically. The capabilities declare elicitation.form because the input-required server sends an elicitation form.
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}}}'1. The legacy handshake still works
Port 8813 runs the 2025-11-25 shape: an initialize request negotiates, then tools flow — no _meta, no mirrored headers.
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8813/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-11-25", "capabilities": {"tools": {}}, "serverInfo": {"name": "stub", "version": "0"}}}
[HTTP 200]2. The modern era: discover, not initialize
On port 8811 the same URL speaks 2026-07-28. initialize is gone — the era starts with server/discover:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/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\":\"d1\",\"method\":\"server/discover\",\"params\":{$META}}"{"jsonrpc": "2.0", "id": "d1", "result": {"resultType": "complete", "supportedVersions": ["2026-07-28"], "capabilities": {"tools": {}}, "ttlMs": 0, "cacheScope": "private", "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "stub-modern", "version": "0"}}, "instructions": "Echo server for the protocol-era examples."}}
[HTTP 200]DiscoverResult requires resultType, supportedVersions, capabilities, ttlMs and cacheScope — cacheScope: "private" says this stub offers no shared cache.
Sending the old handshake to the new era fails with the method-not-found shape — note the JSON-RPC body, which a plain-HTTP 404 would not have:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: initialize' \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"initialize\",\"params\":{$META}}"{"jsonrpc": "2.0", "id": 2, "error": {"code": -32601, "message": "Method not found. This server implements the 2026-07-28 stateless protocol; send server/discover instead of initialize."}}
[HTTP 404]tools/list returns the schema’s required fields — tools, resultType, ttlMs, cacheScope:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
-d "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/list\",\"params\":{$META}}"{"jsonrpc": "2.0", "id": 7, "result": {"tools": [{"name": "echo", "description": "Echo text.", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}], "resultType": "complete", "ttlMs": 0, "cacheScope": "private", "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}
[HTTP 200]tools/call mirrors params.name into Mcp-Name:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/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: echo' \
-d "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"text\":\"hello era\"},\"_meta\":${META#\"_meta\":}}}"{"jsonrpc": "2.0", "id": 8, "result": {"resultType": "complete", "content": [{"type": "text", "text": "hello era"}], "isError": false, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}
[HTTP 200]3. Input required: elicitation over tools/call
Port 8812 adds the multi round-trip request flow. Calling echo without text does not fail — the server answers input_required with an elicitation form, because the request declared clientCapabilities.elicitation.form:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8812/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: echo' \
-d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{},\"_meta\":${META#\"_meta\":}}}"{"jsonrpc": "2.0", "id": 3, "result": {"resultType": "input_required", "requestState": "stub-1", "inputRequests": {"text": {"method": "elicitation/create", "params": {"mode": "form", "message": "Provide the text to echo", "requestedSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}}}}}
[HTTP 200]The retry is a new tools/call with a different JSON-RPC id, the original requestState echoed back verbatim, and inputResponses carrying the ElicitResult for the form:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8812/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: echo' \
-d "{\"jsonrpc\":\"2.0\",\"id\":32,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{},\"inputResponses\":{\"text\":{\"action\":\"accept\",\"content\":{\"text\":\"hello era\"}}},\"requestState\":\"stub-1\",\"_meta\":${META#\"_meta\":}}}"{"jsonrpc": "2.0", "id": 32, "result": {"resultType": "complete", "content": [{"type": "text", "text": "hello era"}], "isError": false, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}
[HTTP 200]A retry without the requestState, or with one the server never issued, is invalid params — not a fresh input_required:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8812/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: echo' \
-d "{\"jsonrpc\":\"2.0\",\"id\":30,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{},\"inputResponses\":{\"text\":{\"action\":\"accept\",\"content\":{\"text\":\"hello era\"}}},\"_meta\":${META#\"_meta\":}}}"{"jsonrpc": "2.0", "id": 30, "error": {"code": -32602, "message": "Invalid params: retry must carry the requestState returned with input_required"}}
[HTTP 400]ElicitResult.action is "accept", "decline" or "cancel" — the user’s answer, not a protocol parameter. A user who declines or cancels is legal: the tool simply does not run, and the server reports that as a business outcome (complete with isError: true), not as a protocol error:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8812/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: echo' \
-d "{\"jsonrpc\":\"2.0\",\"id\":34,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{},\"inputResponses\":{\"text\":{\"action\":\"decline\"}},\"requestState\":\"stub-1\",\"_meta\":${META#\"_meta\":}}}"{"jsonrpc": "2.0", "id": 34, "result": {"resultType": "complete", "isError": true, "content": [{"type": "text", "text": "echo not executed: user declined the elicitation form"}], "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}
[HTTP 200]An action outside the three legal values — reject, say — really is an invalid parameter:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8812/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: echo' \
-d "{\"jsonrpc\":\"2.0\",\"id\":33,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{},\"inputResponses\":{\"text\":{\"action\":\"reject\"}},\"requestState\":\"stub-1\",\"_meta\":${META#\"_meta\":}}}"{"jsonrpc": "2.0", "id": 33, "error": {"code": -32602, "message": "Invalid params: inputResponses.text.action must be accept, decline or cancel"}}
[HTTP 400]4. One-variable negatives for the request rules
Each case below breaks exactly one thing; everything else stays legal. All run against 8811 unless noted.
N1 — drop Mcp-Method (headers no longer mirror the body):
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-d "{\"jsonrpc\":\"2.0\",\"id\":40,\"method\":\"tools/list\",\"params\":{$META}}"{"jsonrpc": "2.0", "id": 40, "error": {"code": -32020, "message": "Header mismatch: Mcp-Method header is required and must match the body method"}}
[HTTP 400]N2 — header/body version mismatch (header says modern, _meta says old):
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":41,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}}}}}'{"jsonrpc": "2.0", "id": 41, "error": {"code": -32020, "message": "Header mismatch: MCP-Protocol-Version header does not match params._meta"}}
[HTTP 400]N3 — a version the server does not implement (header and body agree, on 2025-11-25):
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":42,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}}}}}'{"jsonrpc": "2.0", "id": 42, "error": {"code": -32022, "message": "Unsupported protocol version", "data": {"supported": ["2026-07-28"], "requested": "2025-11-25"}}}
[HTTP 400]N4 — Accept missing a media type (rejected at the HTTP layer, before JSON-RPC):
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
-d "{\"jsonrpc\":\"2.0\",\"id\":43,\"method\":\"tools/list\",\"params\":{$META}}"{"error": "not acceptable", "detail": "POST requires Accept: application/json, text/event-stream"}
[HTTP 406]N5 — unknown method (a 404 with a JSON-RPC body, not a bare HTTP 404):
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: resources/list' \
-d "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"resources/list\",\"params\":{$META}}"{"jsonrpc": "2.0", "id": 44, "error": {"code": -32601, "message": "Method not found"}}
[HTTP 404]N6 — tools/call without Mcp-Name:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/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' \
-d "{\"jsonrpc\":\"2.0\",\"id\":45,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"text\":\"x\"},\"_meta\":${META#\"_meta\":}}}"{"jsonrpc": "2.0", "id": 45, "error": {"code": -32020, "message": "Header mismatch: Mcp-Name header is required and must match params.name"}}
[HTTP 400]N7 — params._meta missing clientInfo only (a body problem, so Invalid params — not HeaderMismatch):
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":46,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}}}}}'{"jsonrpc": "2.0", "id": 46, "error": {"code": -32602, "message": "Invalid params: params._meta.io.modelcontextprotocol/clientInfo is required and must be an object"}}
[HTTP 400]N8 — no MCP-Protocol-Version header at all:
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Method: tools/list' \
-d "{\"jsonrpc\":\"2.0\",\"id\":47,\"method\":\"tools/list\",\"params\":{$META}}"{"jsonrpc": "2.0", "id": 47, "error": {"code": -32020, "message": "Header mismatch: MCP-Protocol-Version header is required"}}
[HTTP 400]N9 — the server needs a form the client never declared (against 8812; capabilities without elicitation):
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8812/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: echo' \
-d '{"jsonrpc":"2.0","id":48,"method":"tools/call","params":{"name":"echo","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'{"jsonrpc": "2.0", "id": 48, "error": {"code": -32021, "message": "Server requires the elicitation capability for this request", "data": {"requiredCapabilities": {"elicitation": {"form": {}}}}}}
[HTTP 400]N10–N13 — type violations (wrong JSON shape; the server answers errors and keeps serving):
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' -d '[]'
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":51,"method":"tools/list","params":[1]}'
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":52,"method":"tools/list","params":{"_meta":7}}'
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8812/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: echo' \
-d "{\"jsonrpc\":\"2.0\",\"id\":53,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"text\":42},\"_meta\":${META#\"_meta\":}}}"
curl -sS -w '\n[HTTP %{http_code}]\n' http://127.0.0.1:8811/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
-d "{\"jsonrpc\":\"2.0\",\"id\":55,\"method\":\"tools/list\",\"params\":{$META}}"{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Invalid Request: body must be a single JSON-RPC request object"}}
[HTTP 400]
{"jsonrpc": "2.0", "id": 51, "error": {"code": -32602, "message": "Invalid params: params must be an object"}}
[HTTP 400]
{"jsonrpc": "2.0", "id": 52, "error": {"code": -32602, "message": "Invalid params: params._meta is required and must be an object"}}
[HTTP 400]
{"jsonrpc": "2.0", "id": 53, "error": {"code": -32602, "message": "Invalid params: arguments.text must be a string"}}
[HTTP 400]
{"jsonrpc": "2.0", "id": 55, "result": {"tools": [{"name": "echo", "description": "Echo text.", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}], "resultType": "complete", "ttlMs": 0, "cacheScope": "private", "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}
[HTTP 200]The last one matters more than it looks: a sloppy server would stringify 42 and echo "42". This one refuses the type instead of guessing, and the next healthy request still succeeds.
Falling back safely
A client that supports both eras may probe with a modern request first — but backward compatibility says to read the 400 body before falling back, because modern servers use 400 for several very different things:
- The body contains a recognized modern JSON-RPC error —
-32022,-32021,-32020— so the server is modern. Do not fall back toinitialize: with-32022retry using a version fromerror.data.supported; with-32020fix your headers or_meta; with-32021declare the capability or skip the request. - The body is empty or not a recognized modern JSON-RPC error: only then fall back to
initializeand continue with the legacy version for subsequent requests.
And 404 does not mean “not a modern endpoint”:
404with a JSON-RPC-32601body is a modern server that does not implement the method — this very page’sinitializeexample. Switch methods (server/discover), not eras.404without a JSON-RPC body is how a legacy HTTP+SSE server answers a POST to an endpoint it does not host. That difference is exactly what the spec uses to tell the two apart.
MCP Inspector runs both eras side by side with --protocol-era legacy|auto|modern; auto does the probe-and-fallback for you. To see the client side of the same migration inside a full tutorial, read Build an MCP client; for what changed in the specification itself, start from the specification index.