MCP 服务器最佳实践:传输、安全与工具设计

MCP 服务器最佳实践:传输、安全与工具设计

MCP 服务器交给真实用户或真实数据之前,需要先定下部署形态、传输和鉴权,再逐条满足 Streamable HTTP 与 OAuth 的 MUST 规则,并确认工具设计让模型能自我纠错。每条做法都对应官方原文的位置、失效模式和恢复步骤;HTTP 规则可以用文中的脚本对线上端点测试。

范围。 只讲服务器一侧,对应 2026-07-28 修订版。客户端行为见官方 客户端最佳实践;开发机上本地 stdio 服务器的加固见 本地 MCP 服务器安全。

一、先选部署形态

多数安全决策取决于服务器跑在哪里、谁能访问它。动手写处理函数之前先定下来。

你的情况选用鉴权官方依据
单用户工具,由宿主在同一台机器上启动stdio从环境变量读取凭据,不要实现 HTTP 授权流程授权是可选的(OPTIONAL);使用 stdio 的实现不应(SHOULD NOT)遵循 HTTP 授权规范(基础协议:Auth)
本机 HTTP 服务器,机器上的其他进程可能访问到它Streamable HTTP绑定 127.0.0.1,校验 Origin,要求令牌,或改用受限访问的 IPCStreamable HTTP “Security & Endpoint”;安全最佳实践“Local MCP Server Compromise”
多用户共享的服务器Streamable HTTPOAuth 2.1 资源服务器:受保护资源元数据、令牌校验、受众绑定授权
代用户调用其他 API 的服务器Streamable HTTP用颁发给你自己的令牌;下游使用单独的凭据,绝不转发客户端的令牌授权“Token Handling”:服务器不得(MUST NOT)接受或转发颁发给其他资源的令牌

二、有意识地做成无状态

2026-07-28 修订版没有协议层会话。每个请求自带 _meta(协议版本、客户端信息、能力),服务器不得(MUST NOT)依赖同一连接上更早的请求(基础协议:Statelessness)。客户端可以在一条连接上穿插无关的请求,所以连接或进程不等于一段对话。

跨请求的状态,比如购物车、草稿、长任务,要放在显式句柄之后,由模型把句柄当作普通工具参数带回来(工具:Stateful Tools,非规范性指导)。

需要记住的东西应该这样做避免
购物车、事务、长任务由创建类工具返回不透明句柄,后续调用把它作为参数传入以连接、进程或 Mcp-Session-Id 头作为状态的键
谁能使用某个句柄以 <user_id>:<handle> 存储,user 来自已验证的令牌,拒绝其他主体把“持有句柄”当作身份认证
句柄格式密码学随机、不透明、会过期计数器、时间戳,或编码了内部结构的标识
过期在创建工具的描述里写明保留规则;句柄过期时返回说明原因的工具执行错误静默失败或笼统的协议错误
横向扩容把共享状态放在句柄背后的存储里,任何实例都能处理任何请求要求相关请求使用同一连接或进程(SHOULD NOT)

只支持本修订版的服务器应忽略请求里的 Mcp-Session-Id,既不生成也不回显会话 ID(Streamable HTTP:Earlier Streamable HTTP Revisions)。

三、远程服务器必须通过的规则

每行给出一条做法、背后官方原文的强度、出处,以及第六节哪个探测项会检查它。“指导”表示原文描述了行为但没有 RFC 2119 关键词。

#做法强度官方出处探测项
1实现 server/discover,并在 supportedVersions 里列出支持的版本MUSTDiscoverydiscover
2对所有入站连接校验 Origin;存在且无效时返回 403 ForbiddenMUSTStreamable HTTP “Security & Endpoint”origin
3请求缺少 Mcp-Method、Mcp-Name、MCP-Protocol-Version 头,或头格式错误、与请求体不一致:返回 400 与 JSON-RPC 错误 -32020(HeaderMismatch)MUSTStreamable HTTP “Server Validation”method-header-missing、method-header-mismatch
4协议版本不受支持:返回 400 与 UnsupportedProtocolVersionError(-32022),并列出支持的版本MUSTStreamable HTTP “Protocol Version Header”unsupported-version、unsupported-version-lists-supported
5RPC 方法不存在:返回 404 与 -32601MUSTStreamable HTTP “Protocol Version Header”unknown-method
6拒绝缺少 MCP-Protocol-Version 的请求,除非你有意服务 2025-06-18 之前的客户端MUST(针对不支持此类客户端的服务器)Streamable HTTP “Protocol Version Header”version-header-missing(WARN)
7对端点的 GET、DELETE 返回 405;忽略 Mcp-Session-Id,不生成会话 IDSHOULDStreamable HTTP “Earlier Streamable HTTP Revisions”get-not-allowed、no-session-id(WARN)
8校验所有工具输入、做访问控制、对调用限流、清洗输出MUST工具“Security Considerations”未探测
9输入校验失败用 isError: true 的结果返回;未知工具作为协议错误(-32602)返回指导工具“Error Handling”tool-input-error、unknown-tool(WARN)
10提供受保护资源元数据,authorization_servers 至少含一项,并通过 WWW-Authenticate 的 resource_metadata 参数或 well-known URI 公布MUSTAuthorization Server Discoveryauth-resource-metadata
11校验访问令牌,只接受以本服务器为受众的令牌,无效或过期的令牌返回 401MUST授权“Token Handling”auth-required、auth-invalid-token、auth-audience
12绝不接受或转发颁发给其他资源的令牌MUST NOT授权“Token Handling”;安全最佳实践“Token Passthrough”auth-audience(只覆盖“接受”)
13权限范围不足时返回 403,WWW-Authenticate 带 error="insufficient_scope"、scope、resource_metadata,并把该操作需要的全部 scope 放进同一次挑战SHOULD授权“Scope Challenge Handling”未探测
14最小权限的 scope:初始集合小、不用通配 scope、不在 scopes_supported 里列出全部目录指导安全最佳实践“Scope Minimization”未探测
15状态句柄不可预测,并在服务端绑定到已认证用户SHOULD安全最佳实践“State Handle Hijacking”未探测

第 8、13、14、15 行依赖你的业务逻辑,或需要一个权限受限的令牌,通用探测覆盖不了,请自己写测试,场景见第五节。

四、把工具设计成模型能自我纠错

规则强度官方出处
名称:1 到 128 个字符,取自 A-Z a-z 0-9 _ - .,区分大小写,在服务器内唯一SHOULD工具“Tool Names”
inputSchema 是合法的 JSON Schema 对象,不能是 null;无参数工具使用 { "type": "object", "additionalProperties": false }MUST(合法对象);推荐写法工具“Tool”
声明了 outputSchema,每个结构化结果都必须符合;同时在文本块里返回序列化 JSON 以兼容旧客户端MUST;SHOULD工具“Output Schema”与“Structured Content”
tools/list 返回当前请求方可用的工具,不随连接变化,也不受其他请求副作用影响;可以随请求上的授权而不同;顺序保持确定不得随连接变化(MUST NOT);顺序 SHOULD工具“Capabilities”
校验与业务规则失败用 isError: true 的结果返回,消息写清错在哪、合法取值是什么(格式、范围)。客户端应(SHOULD)把执行错误交给模型,而协议错误更难恢复指导工具“Error Handling”
不要给密钥或个人信息加 x-mcp-header;头的值对网络中间设备可见SHOULD NOT工具“x-mcp-header”
不要把注解当作安全控制;客户端必须(MUST)把它们视为不可信,除非服务器可信MUST(客户端)工具“Tool”

不适合写进某个工具描述的指引,比如工具之间的关系,放进服务器的 instructions。Server Instructions 实战 讲了各修订版里该字段的送达位置与测试方法。

五、失效模式与恢复

现象可能原因如何确认处理
经过网关的每个请求都返回 400 与 -32020代理丢弃或改写了 Mcp-Method、Mcp-Name、MCP-Protocol-Version;规范把头缺失或不一致都算校验失败分别对源站和经网关的地址跑探测,比较 method-header-*原样转发这三个头,保持请求体与头一致
客户端收到 -32022 后反复重试仍失败它没读 data.supported,或你的列表里的版本它不支持看错误体里的 data.supported 与 data.requested返回准确的列表;客户端用双方都支持的版本重试(版本管理)
旧版客户端第一个请求就失败服务器只支持新版,而客户端以 initialize 开场。SDK 2.3.0 对缺少 MCP-Protocol-Version 的请求返回 400 与 -32600version-header-missing接受这一结果,或按对应旧修订版的描述实现兼容
浏览器应用收到 403Origin 白名单里没有该应用的来源带上应用的 Origin 重发请求添加精确的来源。探测只发送敌意来源,请为每个允许的来源另写测试
开启鉴权后每次调用都返回 401令牌是为别的受众签发的,或服务器比对的资源标识与客户端请求的不一致带 --token 运行探测,discover 必须先通过让令牌绑定到服务器的规范 URI,并在服务器上配置同一个 URI
颁发给其他服务的令牌被接受没有任何一层检查受众。SDK 2.3.0 里 AuthSettings.validate_token_resource 默认不设置:SDK 会告警、不检查,并说明 3.0 起默认为 Trueauth-audience 失败并显示 got 200(第六节复现)设置 validate_token_resource=True,或在你自己的 TokenVerifier 里检查受众
客户端不停地让用户重新授权服务器每次只挑战一个 scope统计同一操作收到的 403 挑战次数把操作需要的所有 scope 放进一次挑战;客户端应限制重试次数
一个用户能访问另一个用户的购物车句柄可猜测,或没有绑定调用者用第二个用户的令牌回放句柄随机不透明句柄,以 <user_id>:<handle> 为键,user 取自已验证的令牌
模型反复发出同一个错误调用失败以协议错误或含糊文本返回对比 JSON-RPC 错误与 isError 结果,读消息内容返回 isError: true,指明字段与合法取值
两条连接得到的工具列表不同列表依赖连接状态或无序存储在两条连接上各调一次 tools/list 并比较只由请求上的授权计算,并排序

探测覆盖不到的那几行,建议在自己的 CI 里加入这些场景:权限不足的令牌得到 403,挑战里列出所需的全部 scope;第二个用户的令牌不能使用第一个用户的句柄;过期句柄返回 isError 结果;两条连接对同一令牌得到相同的 tools/list;超出限流的工具调用被拒绝。

六、运行 HTTP 探测脚本

probe_http.py 只用 Python 标准库。它发送第三节中可探测各行对应的请求,每项检查输出一行。FAIL 表示未满足某条 MUST,WARN 表示未遵循某条 SHOULD 或工具页的错误处理指导。退出码 0 表示没有 FAIL,1 表示至少一个 FAIL,2 表示端点无法连接。

python3 probe_http.py http://127.0.0.1:8000/mcp --tool echo --bad-args '{"text":123}'
python3 probe_http.py http://127.0.0.1:8001/mcp --tool echo --bad-args '{"text":123}' \
  --token good-token --expect-auth --bad-audience-token other-audience-token
probe_http.py
#!/usr/bin/env python3
"""Probe a Streamable HTTP MCP endpoint against the HTTP rules of spec 2026-07-28 (stdlib only).

Usage: probe_http.py URL [--tool NAME --bad-args JSON] [--token T [--expect-auth] [--bad-audience-token T]]
Levels: FAIL = a MUST in the spec is not met, WARN = a SHOULD or the tools page's error guidance is not followed
(version-header-missing is WARN because a server may serve pre-2025-06-18 clients and treat the request as 2025-03-26).
Exit code: 0 no FAIL, 1 at least one FAIL, 2 endpoint unreachable.
"""
import argparse
import http.client
import json
import sys
from urllib.parse import urlsplit

VERSION = "2026-07-28"
EVIL_ORIGIN = "https://evil.example"
counts = {"PASS": 0, "WARN": 0, "FAIL": 0}


def meta(version):
    return {
        "io.modelcontextprotocol/protocolVersion": version,
        "io.modelcontextprotocol/clientInfo": {"name": "probe", "version": "0"},
        "io.modelcontextprotocol/clientCapabilities": {},
    }


def parse(raw):
    """Return the JSON-RPC response from a JSON body or an SSE stream, else None."""
    try:
        return json.loads(raw)
    except ValueError:
        pass
    found = None
    for line in raw.splitlines():
        if line.startswith("data:"):
            try:
                msg = json.loads(line[5:].strip())
            except ValueError:
                continue
            if isinstance(msg, dict) and ("result" in msg or "error" in msg):
                found = msg
    return found


def request(url, verb="POST", method=None, params=None, headers=None, version=VERSION):
    """Send one request; a header whose value is None is removed. Returns (status, headers, body)."""
    parts = urlsplit(url)
    cls = http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection
    conn = cls(parts.netloc, timeout=10)
    hdrs = {"Accept": "application/json, text/event-stream", "MCP-Protocol-Version": version}
    payload = None
    if verb == "POST":
        hdrs.update({"Content-Type": "application/json", "Mcp-Method": method})
        if params and "name" in params:
            hdrs["Mcp-Name"] = params["name"]
        payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method,
                              "params": {**(params or {}), "_meta": meta(version)}})
    hdrs.update(headers or {})
    conn.request(verb, parts.path or "/", payload, {k: v for k, v in hdrs.items() if v is not None})
    resp = conn.getresponse()
    raw = resp.read().decode("utf-8", "replace")
    conn.close()
    return resp.status, resp, parse(raw)


def code(body):
    err = body.get("error") if isinstance(body, dict) else None
    return err.get("code") if isinstance(err, dict) else None


def show(status, rpc_code):
    return "%s/%s" % (status, rpc_code) if rpc_code is not None else str(status)


def report(name, ok, detail, level="FAIL"):
    status = "PASS" if ok else level
    counts[status] += 1
    print("%s %s: %s" % (status, name, detail))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("url")
    ap.add_argument("--tool", help="name of an existing tool, enables the tool-error check")
    ap.add_argument("--bad-args", default="{}", help="JSON arguments that violate the tool's input schema")
    ap.add_argument("--token", help="bearer token that the server accepts")
    ap.add_argument("--expect-auth", action="store_true", help="the endpoint is protected: run the auth checks")
    ap.add_argument("--bad-audience-token", help="a valid token issued for a different resource")
    args = ap.parse_args()
    if args.expect_auth and not args.token:
        ap.error("--expect-auth needs --token (the other checks must get past authentication)")
    auth = {"Authorization": "Bearer " + args.token} if args.token else {}
    url = args.url

    try:
        status, _, body = request(url, method="server/discover", headers=auth)
    except (OSError, http.client.HTTPException) as exc:
        print("ERROR endpoint unreachable: %s" % exc)
        return 2
    result = body.get("result") if isinstance(body, dict) else None
    ok = status == 200 and isinstance(result, dict) and VERSION in result.get("supportedVersions", [])
    report("discover", ok, "HTTP %s, supportedVersions %s" % (status, (result or {}).get("supportedVersions")))
    tools = "tools" in (result or {}).get("capabilities", {})

    def expect(name, want_status, want_code, **kw):
        st, _, b = request(url, **{"headers": auth, **kw})
        report(name, st == want_status and (want_code is None or code(b) == want_code),
               "want %s, got %s" % (show(want_status, want_code), show(st, code(b))))
        return b

    expect("origin", 403, None, method="server/discover", headers={**auth, "Origin": EVIL_ORIGIN})
    expect("method-header-missing", 400, -32020, method="server/discover", headers={**auth, "Mcp-Method": None})
    expect("method-header-mismatch", 400, -32020, method="server/discover", headers={**auth, "Mcp-Method": "tools/list"})
    b = expect("unsupported-version", 400, -32022, method="server/discover", version="2099-01-01",
               headers={**auth})
    supported = ((b or {}).get("error") or {}).get("data", {}).get("supported") if isinstance(b, dict) else None
    report("unsupported-version-lists-supported", bool(supported) and VERSION in supported, "data.supported = %s" % supported)
    expect("unknown-method", 404, -32601, method="nope/nothing")

    st, _, b = request(url, method="server/discover", headers={**auth, "MCP-Protocol-Version": None})
    report("version-header-missing", st == 400, "want 400, got %s" % show(st, code(b)), "WARN")
    st, _, _ = request(url, verb="GET", headers=auth)
    report("get-not-allowed", st == 405, "want 405, got %s" % st, "WARN")
    st, resp, _ = request(url, method="server/discover", headers={**auth, "Mcp-Session-Id": "probe-session"})
    report("no-session-id", resp.getheader("Mcp-Session-Id") is None, "Mcp-Session-Id in response: %s" % resp.getheader("Mcp-Session-Id"), "WARN")

    if args.tool:
        st, _, b = request(url, method="tools/call", params={"name": args.tool, "arguments": json.loads(args.bad_args)}, headers=auth)
        is_error = isinstance(b, dict) and (b.get("result") or {}).get("isError") is True
        report("tool-input-error", st == 200 and is_error, "want 200 with result.isError=true, got %s" % show(st, "isError" if is_error else code(b)), "WARN")
        st, _, b = request(url, method="tools/call", params={"name": "no_such_tool", "arguments": {}}, headers=auth)
        report("unknown-tool", code(b) == -32602, "want JSON-RPC error -32602, got %s" % show(st, code(b) or "result"), "WARN")
    elif tools:
        print("SKIP tool checks: pass --tool NAME --bad-args JSON")

    if args.expect_auth:
        st, resp, b = request(url, method="server/discover")
        challenge = resp.getheader("WWW-Authenticate") or ""
        report("auth-required", st == 401, "no token: want 401, got %s" % st)
        report("auth-invalid-token", request(url, method="server/discover", headers={"Authorization": "Bearer invalid"})[0] == 401,
               "garbage token must get 401")
        if args.bad_audience_token:
            st = request(url, method="server/discover", headers={"Authorization": "Bearer " + args.bad_audience_token})[0]
            report("auth-audience", st == 401, "token for another resource: want 401, got %s" % st)
        meta_url = challenge.split('resource_metadata="')[1].split('"')[0] if 'resource_metadata="' in challenge else None
        origin = "%s://%s" % (urlsplit(url).scheme, urlsplit(url).netloc)
        well_known = "/.well-known/oauth-protected-resource"
        found = None
        for candidate in [meta_url, origin + well_known + urlsplit(url).path, origin + well_known]:
            if not candidate:
                continue
            st, _, doc = request(candidate, verb="GET", headers={})
            if st == 200 and isinstance(doc, dict):
                found = doc
                break
        report("auth-resource-metadata", bool(found and found.get("authorization_servers")),
               "challenge resource_metadata=%s; document authorization_servers=%s" % (meta_url, (found or {}).get("authorization_servers")))

    print("%d pass, %d warn, %d fail" % (counts["PASS"], counts["WARN"], counts["FAIL"]))
    return 1 if counts["FAIL"] else 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except (OSError, http.client.HTTPException) as exc:
        print("ERROR connection failed: %s" % exc)
        sys.exit(2)

两个参照服务器使用官方 Python SDK(pip install mcp==2.3.0):

ref_server.py
"""Official Python SDK (mcp==2.3.0) server on Streamable HTTP, http://127.0.0.1:8000/mcp, no auth."""
from mcp.server.mcpserver import MCPServer

server = MCPServer("ref")


@server.tool()
def echo(text: str) -> str:
    """Echo text."""
    return text


if __name__ == "__main__":
    server.run("streamable-http")
auth_ref_server.py
"""Same server behind bearer-token auth, http://127.0.0.1:8001/mcp.

Tokens: good-token is issued for this server; other-audience-token is valid but issued for another resource.
"""
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer

RESOURCE = "http://127.0.0.1:8001/mcp"


class Verifier:
    async def verify_token(self, token: str) -> AccessToken | None:
        if token == "good-token":
            return AccessToken(token=token, client_id="probe", scopes=[], resource=RESOURCE)
        if token == "other-audience-token":
            return AccessToken(token=token, client_id="probe", scopes=[], resource="https://other.example/mcp")
        return None


server = MCPServer(
    "ref-auth",
    token_verifier=Verifier(),
    auth=AuthSettings(
        issuer_url="https://auth.example.com",
        resource_server_url=RESOURCE,
        validate_token_resource=True,
    ),
)


@server.tool()
def echo(text: str) -> str:
    """Echo text."""
    return text


if __name__ == "__main__":
    server.run("streamable-http", port=8001)

在这套环境(Python 3.14、macOS、mcp==2.3.0)上探测脚本的结果:

目标结果
不带鉴权的 SDK 服务器(ref_server.py)11 通过、1 警告、0 失败
带 bearer 鉴权的 SDK 服务器(auth_ref_server.py)15 通过、1 警告、0 失败
同一个鉴权服务器去掉 validate_token_resource(SDK 默认值)1 个失败:auth-audience,收到 200
按规范原文写的桩,不带鉴权与带鉴权12 通过、0 警告、0 失败;16 通过、0 警告、0 失败
桩每次破坏一条规则,共 11 个变体8 个变体被对应检查判为失败,3 个变体只给出警告,没有一个静默通过

鉴权服务器的完整输出:

PASS discover: HTTP 200, supportedVersions ['2026-07-28']
PASS origin: want 403, got 403
PASS method-header-missing: want 400/-32020, got 400/-32020
PASS method-header-mismatch: want 400/-32020, got 400/-32020
PASS unsupported-version: want 400/-32022, got 400/-32022
PASS unsupported-version-lists-supported: data.supported = ['2026-07-28']
PASS unknown-method: want 404/-32601, got 404/-32601
PASS version-header-missing: want 400, got 400/-32600
PASS get-not-allowed: want 405, got 405
PASS no-session-id: Mcp-Session-Id in response: None
PASS tool-input-error: want 200 with result.isError=true, got 200/isError
WARN unknown-tool: want JSON-RPC error -32602, got 200/result
PASS auth-required: no token: want 401, got 401
PASS auth-invalid-token: garbage token must get 401
PASS auth-audience: token for another resource: want 401, got 401
PASS auth-resource-metadata: challenge resource_metadata=http://127.0.0.1:8001/.well-known/oauth-protected-resource/mcp; document authorization_servers=['https://auth.example.com']
15 pass, 1 warn, 0 fail

怎么看这个结果:

  • 那条警告是发现,不是探测脚本的缺陷。 工具页把“Unknown tool”列在协议错误下,示例错误码是 -32602;SDK 2.3.0 返回的是带 isError: true 的普通结果。这段文字属于指导而非 MUST,模型对两种返回都能恢复,但请有意识地选定一种行为并写测试。
  • 默认受众那一行说明了为什么需要反向测试。 服务器对颁发给 https://other.example/mcp 的令牌返回了 200。没有崩溃,正常路径也能跑通,只有反向测试才发现得了。
  • 通过只证明所列行为在这一套环境里成立。 探测不检查:白名单内的来源能否通过、TLS、限流、scope 挑战、流式(SSE)响应、取消、x-mcp-header 镜像、多轮往返请求、输出清洗和负载。桩与 SDK 通过同样的检查,是对规范的两次独立阅读,但排除不了共同的误读,所以请把第三节的对照表当作评审清单。

七、上线前核对

  • 已按第一节选定部署形态并记录理由。
  • 没有任何逻辑依赖连接或进程身份;所有句柄不透明、会过期、绑定所有者。
  • Origin 校验、头校验与版本错误都通过探测,网关会转发三个镜像头。
  • 受保护的服务器:无效、过期、受众错误的令牌都返回 401;元数据文档指明了授权服务器;没有令牌被转发到下游。
  • 工具名称、schema 和结果形态符合第四节,校验失败以模型可据以行动的 isError 结果返回。
  • 第 8、13、14、15 行在你自己的测试集里有对应用例。
  • 更换 SDK、网关或授权服务器后重新运行探测。

版本与验证说明

规范事实取自站内发布的 2026-07-28 页面。第六节的实验在本地用官方 Python SDK 2.3.0 完成,文中已标明。

来源