跨协议时代迁移:从 2025-11-25 到 2026-07-28

跨协议时代迁移:从 2025-11-25 到 2026-07-28

2026-07-28 修订让 MCP 变成无状态协议:没有 initialize 握手,用 server/discover 取代协商出的会话,并且每个请求都必须携带请求级元数据。如果你的客户端要同时对接 2025-11-25 服务器和 2026-07-28 服务器,本页用一个 echo 服务器把全部差异走一遍——先走正例,再对每条规则做单变量负例。

本页是什么。 下面每条命令都真实运行于本页内嵌的最小服务器——仅用 Python 标准库、合成数据、仅监听 loopback、只有一个 echo 工具。它是教学夹具,不是协议实现:只强制示例所需的请求形态(镜像头部、params._meta、input_required 续传、elicitation 能力检查),不包含鉴权、会话、流、缓存或 x-mcp-header 参数镜像。规范正文见 Streamable HTTP 传输、MRTR 与 tools。

每个现代请求必须携带什么

传输规范把选定的 body 字段镜像到 HTTP 头,tools 规范则要求每个请求带 _meta。因此一个合规的 2026-07-28 请求携带:

  • 每个 POST 都带 Accept: application/json, text/event-stream。
  • MCP-Protocol-Version: 2026-07-28——且该头必须等于 params._meta.io.modelcontextprotocol/protocolVersion。
  • 每个请求都带 Mcp-Method: <method>,镜像 body 的 method。
  • tools/call、resources/read、prompts/get 另带 Mcp-Name——tools/call 与 prompts/get 镜像 params.name,而 resources/read 镜像的是 params.uri。
  • params._meta 携带 io.modelcontextprotocol/protocolVersion、io.modelcontextprotocol/clientInfo 与 io.modelcontextprotocol/clientCapabilities。如果请求会让服务器向用户发表单,还必须声明 clientCapabilities.elicitation.form。

服务器用 schema 自己的错误结构回应违规,各归其层:

违规HTTPJSON-RPC
Accept 缺一种媒体类型406纯 HTTP 拒绝,先于 JSON-RPC 处理
body 不是合法 JSON400-32700 Parse error
顶层不是 JSON-RPC 请求对象400-32600 Invalid Request
params / _meta / arguments / inputResponses 类型错误或缺必需字段400-32602 Invalid params
标准头(MCP-Protocol-Version、Mcp-Method、Mcp-Name)缺失或与 body 不匹配400-32020 HeaderMismatch
头与 _meta 一致,但落在服务器不实现的版本上400-32022 UnsupportedProtocolVersion,含 error.data.supported / error.data.requested
服务器需要客户端未声明的 elicitation 表单400-32021 MissingRequiredClientCapability,含 error.data.requiredCapabilities
未知 RPC 方法——包括 initialize404-32601 Method not found

成功结果同样遵守 schema:DiscoverResult 必需 resultType、supportedVersions、capabilities、ttlMs、cacheScope;ListToolsResult 必需 tools、resultType、ttlMs、cacheScope;CallToolResult 必需 resultType 与 content(isError 标记工具级失败)。需要更多输入的请求以 resultType: "input_required" 应答,进入多轮往返续传。

服务器

把下面的文件保存为 era_stub.py。它在你指定的端口上应答 /mcp,接受一个参数:legacy 表现如 2025-11-25 服务器,modern 表现如 2026-07-28 服务器,input-required 同 modern,但缺少 text 参数的 tools/call 应答 input_required 并期待规范形态的续传。

"""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()

启动三个服务器

在你自己的终端(bash 或 zsh)运行。trap 只杀本片段启动的三个 $! PID,不会碰你已有的后台任务;退出 shell(exit 或 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 1

请求反复复用的唯一变量

下面的每条请求都完整内联自己的全部头,因此每条命令在 bash 与 zsh 中都可以直接复制运行——不依赖未加引号变量的词拆分。唯一的变量是 $META(params._meta 对象),它始终在双引号 JSON 字符串内部插值,两种 shell 对此处理完全相同。能力里声明了 elicitation.form,因为 input-required 服务器会发送 elicitation 表单。

META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}}}'

1. 旧版服务器以 initialize 握手开场

端口 8813 是 2025-11-25 形态:先发 initialize 请求协商,之后工具流量照常——没有 _meta,也没有镜像头。

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. 现代服务器没有握手,从 server/discover 开始

同一个 URL 在端口 8811 上说 2026-07-28。initialize 已消失——时代从 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 必需 resultType、supportedVersions、capabilities、ttlMs、cacheScope——cacheScope: "private" 表示本教学夹具不提供共享缓存。

把旧握手发进新时代会得到 method-not-found 形态的失败——注意 JSON-RPC 正文,纯 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: 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 返回 schema 必需字段——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 把 params.name 镜像进 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. 现代服务器可以在请求中途索要输入

端口 8812 加入多轮往返请求流程。不带 text 调用 echo 并不会失败——因为请求声明了 clientCapabilities.elicitation.form,服务器以带 elicitation 表单的 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\":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]

续传是一次新的 tools/call:换一个 JSON-RPC id,把原 requestState 原样带回,并在 inputResponses 里携带表单的 ElicitResult:

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]

不带 requestState 的续传,或带一个服务器从未下发过的 state,是 Invalid params——而不是一次新的 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 只有 "accept"、"decline"、"cancel" 三个合法值——它是用户的回答,不是协议参数。用户拒绝或取消是合法的:工具只是不执行,服务器把它作为业务结果上报(complete 且 isError: true),而不是协议错误:

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]

超出三个合法值的 action——比如 reject——才是真正的非法参数:

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. 请求规则的单变量负例

下面每个用例只破坏一件事,其余保持合法。除特别说明外都打 8811。

N1——去掉 Mcp-Method(头不再镜像 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——头与 body 版本不符(头说现代,_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":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——服务器不实现的版本(头与 body 一致,都是 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 缺一种媒体类型(在 HTTP 层被拒,先于 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——未知方法(带 JSON-RPC 正文的 404,不是裸 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 缺 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 仅缺 clientInfo(body 问题,所以是 Invalid params——不是 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——完全没有 MCP-Protocol-Version 头:

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——服务器需要的表单客户端从未声明(打 8812;能力里没有 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——类型违规(JSON 形态错误;服务器应答错误后继续服务):

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]

最后一条比看上去更重要:不严谨的服务器会把 42 字符串化后回显 "42"。这一台拒绝类型而不是瞎猜,且下一个健康请求依然成功。

安全回退

同时支持两个时代的客户端可以先发一个现代请求探测——但向后兼容要求先读 400 的正文再决定回退,因为现代服务器会用 400 表达好几种完全不同的情况:

  • 正文包含可识别的现代 JSON-RPC 错误——-32022、-32021、-32020——说明服务器就是现代的。不要回退到 initialize:-32022 用 error.data.supported 里列出的版本重试;-32020 修你的头或 _meta;-32021 声明能力或跳过该请求。
  • 正文为空或不是可识别的现代 JSON-RPC 错误:只有这时才回退到 initialize,后续请求继续用旧版协议。

而 404 不等于"这不是现代端点":

  • 404 带 JSON-RPC -32601 正文,是现代服务器未实现该方法——正是本页 initialize 示例的样子。该换方法(server/discover),不是换时代。
  • 404 不带 JSON-RPC 正文,是旧版 HTTP+SSE 服务器对未托管端点 POST 的应答方式。规范正是用这一差异区分两者。

MCP Inspector 用 --protocol-era legacy|auto|modern 并排运行两个时代;auto 自动完成探测与回退。想在完整教程里看同一迁移的客户端侧,读构建 MCP 客户端;想了解规范本身的变化,从规范索引开始。