Skip to content

MCP 信任边界:工具身份、授权与结果链 ​

1. 场景与协议基准 ​

Agent 连接多个 MCP server,读取工具目录后选择工具,工具再访问文件、业务 API 或检索系统。检查对象包括客户端的工具选择和执行控制、server 的用户授权、下游 API 的权限映射。模型、客户端、MCP server 与下游服务各自拥有不同的信任边界。

本文协议事实固定参考 2025-11-25 版;目标协商到其他版本时,保存 initialize 结果并对照该版本。工具名在单个 server 内唯一,tools/list 支持分页;工具 annotations 来自非可信 server 时必须按不可信数据处理。这些属性由 MCP Tools 规范 定义。完整身份应在实验记录中写成 server 身份与工具名的组合。

HTTP 授权检查和 stdio 本地启动是两条路径。HTTP 授权规范要求 access token 面向目标 MCP server,客户端在授权及 token 请求中提供 resource;禁止把收到的 token 直接透传给下游 API。stdio 的凭据接入不按同一 HTTP OAuth 流程处理。参见 MCP Authorization。

2. 输入信号与边界地图 ​

输入信号控制方需要核验的边界
描述、参数说明或结果声称“调用前必须读取另一文件”server 或其数据源内容能否增加工具权限
同名工具来自两个 server两个独立连接UI、缓存与执行路由是否保留 server 身份
目录更新后描述、schema 或目的地改变可更新 server旧的信任决定是否被应用到新能力
工具 annotation 声称只读,但调用会写入server 元数据执行权限是否依赖未经验证的提示字段
HTTP token 只检查签名,不检查目标资源授权服务与 MCP server服务 A 的凭据是否能用于服务 B
请求只有 Mcp-Session-Id 或跨用户复用它客户端/代理session 是否错误地替代身份检查
发现元数据包含可变 URL 或重定向远端 server自动请求的网络目的地是否受约束
本地 server 的启动命令、工作目录、环境可被项目修改本地配置/包一次连接是否导致额外进程或凭据暴露

3. 工作流与并行分支 ​

text
记录 initialize 协商版本 / transport / server 实例
  → 完整读取工具目录(包含分页)与资源来源
  → 记录任务授权、工具选择、最终参数、执行结果
    ├─ 描述或结果引导调用 → 间接提示注入
    ├─ 名称相同、server 不同 → 工具身份混淆
    ├─ 审批后参数或目录变化 → 授权与执行脱节
    ├─ HTTP 凭据跨资源可用 → audience / 代理授权
    ├─ session 跨用户可用 → 身份与会话绑定
    └─ discovery / 本地启动越界 → 网络或进程边界

一次只替换一个维度。正常工具调用必须先成功;随后换 server、参数、工具版本、主体或 audience,并保持其他状态不变。每个负例都有正常对照,避免把 schema 错误、过期凭据或初始化失败误作授权控制。

3.1 工具选择与审批绑定 ​

在合成 server 中暴露 canary.record,它只向实验内存记录标记。将注入放在工具描述、schema 字段描述、工具结果三处,分别测试。记录模型选择工具前实际看到的目录版本。

对同一工具准备两版目录,第二版改变一个参数的语义或目标位置。正常完成目录刷新后,观察客户端是否重新建立相应信任决定。目录改变本身是协议能力;问题发生在旧批准被用于不同 server、不同参数或不同操作时。

审批界面展示什么、执行器收到什么、server 实际做什么,需要用同一个 call_id 对齐。审批了对象 A,却向对象 B 执行动作,才是该分支的具体越界证据。若产品按策略自动授权,则比较策略允许的操作集合与实际操作,无须假设每次调用都有弹窗。

3.2 HTTP 授权、代理与会话 ​

准备同一实验授权服务签发的两组有效凭据,分别面向 MCP-A 与 MCP-B;不在日志里输出 token 正文。先确认各自在自身服务成功,再交叉使用。若 token 不透明,应从授权服务的验证或 introspection 结果取得 audience 证据,不能假设所有 token 都是 JWT。

分别测试“用户 A 的有效凭据 + A 会话”“用户 B 的有效凭据 + A 会话”“无凭据 + A 会话”。记录每个请求的验证主体、server 绑定主体和数据归属。HTTP 授权不能只在初次建立逻辑会话时检查;规范要求每个客户端 HTTP 请求携带授权信息,参见前述 Authorization 规范。

代理模式还需核验 MCP client 的同意记录与下游 OAuth client 身份。静态下游 client ID、动态注册、已存在的同意 cookie,以及缺失逐 client 同意控制共同形成 confused deputy 风险;仅观察到其中一项时保留为条件缺口。官方安全实践 描述了这些前置条件及 token passthrough 风险。

3.3 网络与本地启动 ​

对于 Streamable HTTP,分别记录合法 Origin、非法 Origin、无 Origin 的结果。规范要求校验 Origin,存在且非法时返回 403;绑定 localhost 是本地服务的建议。无 Origin 的本地客户端请求与浏览器跨源请求应分开分析。MCP Transports

元数据发现实验使用本地模拟授权服务和固定 canary 端点,逐层记录 resource_metadata、授权服务元数据、重定向与最终请求目的地。stdio 则记录固定启动配置、实际可执行文件、参数、环境变量名称和子进程;不要把 HTTP Origin 测试结果套到 stdio。

3.4 直接连接实验 HTTP MCP server ​

从客户端的连接配置取得 MCP endpoint,从已完成的实验 OAuth 登录取得面向该 server 的 access token。不要把业务 API 的 token 随手替换成 MCP token。记录 endpoint 对应的 server 实例、授权主体和协议版本,随后设置 LAB_MCP_URL、LAB_MCP_AUTH(完整 Authorization 值)及可选的 LAB_MCP_ORIGIN。

下面保存为 mcp_http_capture.py,执行 initialize、initialized notification、目录分页或一次 tools/call,并保存每条请求与响应。它面向普通 Streamable HTTP 调用,不提供 OAuth 登录、sampling、elicitation 或 task 轮询;遇到这些交互时,使用目标已有 MCP 客户端并导出同样的消息记录。

python
import argparse
import json
import os
from pathlib import Path
import urllib.error
import urllib.request


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def decode_messages(raw, content_type):
    if not raw:
        return []
    text = raw.decode("utf-8")
    if "text/event-stream" not in content_type:
        return [json.loads(text)]
    events, lines = [], []
    for line in text.splitlines() + [""]:
        if line.startswith("data:"):
            lines.append(line[5:].lstrip(" "))
        elif not line and lines:
            events.append(json.loads("\n".join(lines)))
            lines = []
    return events


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--output", required=True)
    ap.add_argument("--tool")
    ap.add_argument("--arguments", help="JSON file for an observed tool schema")
    args = ap.parse_args()
    output = Path(args.output)
    output.mkdir(parents=True, exist_ok=True)
    opener = urllib.request.build_opener(NoRedirect)
    state = {"session": "", "version": "", "sequence": 0}

    def post(message, label):
        state["sequence"] += 1
        prefix = output / f"{state['sequence']:02d}-{label}"
        payload = json.dumps(message).encode()
        headers = {"Content-Type": "application/json",
                   "Accept": "application/json, text/event-stream"}
        for env, key in (("LAB_MCP_AUTH", "Authorization"), ("LAB_MCP_ORIGIN", "Origin")):
            if os.environ.get(env):
                headers[key] = os.environ[env]
        if state["session"]:
            headers["Mcp-Session-Id"] = state["session"]
        if state["version"]:
            headers["MCP-Protocol-Version"] = state["version"]
        request = urllib.request.Request(os.environ["LAB_MCP_URL"], data=payload,
                                         headers=headers, method="POST")
        try:
            response = opener.open(request, timeout=60)
        except urllib.error.HTTPError as error:
            response = error
        with response:
            raw = response.read()
            content_type = response.headers.get("Content-Type", "")
            if response.headers.get("Mcp-Session-Id"):
                state["session"] = response.headers["Mcp-Session-Id"]
            prefix.with_suffix(".request.json").write_bytes(payload)
            prefix.with_suffix(".response.bin").write_bytes(raw)
            prefix.with_suffix(".meta.json").write_text(json.dumps({
                "status": response.status, "content_type": content_type,
                "session_present": bool(state["session"])}), encoding="utf-8")
            if response.status >= 300:
                raise RuntimeError(f"{label}: HTTP {response.status}; see captured response")
        messages = decode_messages(raw, content_type)
        if "id" not in message:
            return None
        matches = [m for m in messages if m.get("id") == message["id"]
                   and ("result" in m or "error" in m)]
        if len(matches) != 1 or "error" in matches[0]:
            raise RuntimeError(f"{label}: expected one JSON-RPC result; inspect messages")
        return matches[0]["result"]

    init = post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
        "protocolVersion": "2025-11-25", "capabilities": {},
        "clientInfo": {"name": "reverselab-capture", "version": "1.0"}}}, "initialize")
    state["version"] = init["protocolVersion"]
    post({"jsonrpc": "2.0", "method": "notifications/initialized"}, "initialized")
    (output / "session.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
    if args.tool:
        arguments = json.loads(Path(args.arguments).read_text(encoding="utf-8")) if args.arguments else {}
        result = post({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
                       "params": {"name": args.tool, "arguments": arguments}}, "call")
    else:
        catalog, cursor, seen = [], None, set()
        while True:
            params = {"cursor": cursor} if cursor else {}
            page = post({"jsonrpc": "2.0", "id": state["sequence"] + 1,
                         "method": "tools/list", "params": params}, "list")
            catalog.extend(page["tools"])
            cursor = page.get("nextCursor")
            if not cursor:
                break
            if cursor in seen:
                raise RuntimeError("repeated pagination cursor")
            seen.add(cursor)
        result = {"tools": catalog}
    (output / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
    print(json.dumps({"protocol_version": state["version"], "output": str(output)}))


if __name__ == "__main__":
    main()
powershell
python mcp_http_capture.py --output exports/general/demo-mcp/catalog-a
# 根据 catalog-a/result.json 中真实存在的工具名/schema 创建 arguments.json。
python mcp_http_capture.py --tool $observedToolName --arguments arguments.json --output exports/general/demo-mcp/call-a

不要把文中的 canary.record 假定为远端已安装工具。真实写操作用实验 server 提供的 canary 或产品专用测试对象;先读取目录确认名字、输入 schema 与副作用。server 若返回其他协议版本,记录实际值并确认客户端流程兼容后继续;不以脚本请求的版本冒充协商结果。

将 A/B 身份、正常/错误 audience、正常/变化后的工具参数分别放到独立 output 目录。比较请求正文、JSON-RPC error、工具 isError 和业务状态。HTTP 401 需先恢复该身份的正常调用对照;目录缺少工具时检查 server 能力与分页,不开始猜测调用名。session.json 含实验 session ID,只保存于本地案件目录。

4. 离线授权差分试件 ​

保存为 mcp_boundary_matrix.py,运行 python mcp_boundary_matrix.py。此脚本使用已经验证过身份的合成记录,演示将批准绑定到用户、server、目录版本、工具和精确参数后的差分结果;它不执行 OAuth 验签、不启动 MCP server,也不是生产授权库。

python
from copy import deepcopy
import hashlib
import json


def argument_hash(arguments):
    # 本试件只使用无浮点数的简单 JSON,不作为跨语言规范化算法。
    raw = json.dumps(arguments, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(raw.encode()).hexdigest()


def decision(call, grant):
    checks = {
        "principal": call["verified_subject"] == grant["subject"],
        "audience": call["verified_audience"] == grant["server"],
        "server": call["server"] == grant["server"],
        "tool": call["tool"] == grant["tool"],
        "catalog": call["catalog_revision"] == grant["catalog_revision"],
        "arguments": argument_hash(call["arguments"]) == grant["arguments_hash"],
    }
    failed = [name for name, ok in checks.items() if not ok]
    return {"allow": not failed, "failed": failed}


def main():
    server = "https://mcp.example.test/lab"
    args = {"marker": "SYNTHETIC_MCP_CANARY", "object": "demo-a"}
    grant = {"subject": "user-a", "server": server, "tool": "canary.record",
             "catalog_revision": "r1", "arguments_hash": argument_hash(args)}
    baseline = {"verified_subject": "user-a", "verified_audience": server,
                "server": server, "tool": "canary.record",
                "catalog_revision": "r1", "arguments": args}
    cases = [("baseline", baseline, True)]
    replacements = {
        "subject": {"verified_subject": "user-b"},
        "audience": {"verified_audience": "https://api.example.test/other"},
        "server": {"server": "https://other.example.test/lab"},
        "catalog": {"catalog_revision": "r2"},
        "arguments": {"arguments": {**args, "object": "demo-b"}},
    }
    for name, patch in replacements.items():
        changed = deepcopy(baseline)
        changed.update(patch)
        cases.append((name, changed, False))
    output = []
    effects = []
    for name, call, expected in cases:
        result = decision(call, grant)
        assert result["allow"] == expected, name
        if result["allow"]:
            effects.append(call["arguments"]["object"])
        output.append({"case": name, **result})
    assert effects == ["demo-a"]
    print(json.dumps({"kind": "offline-model", "decisions": output,
                      "effects": effects}, indent=2))


if __name__ == "__main__":
    main()

对实际目标复用这些变量,而不是把脚本的判定当成目标结果。若目标允许某些参数范围,应把允许集合明确写入实验前提;精确参数绑定只是本合成试件的策略。目录指纹只能检测变化,不能证明 server 实现与描述一致。

5. 证据、判定与失败分支 ​

结果证据要求继续方向
只出现异常工具建议原始描述/结果与模型调用记录选择层注入,转间接提示注入篇
错误 server 收到同名工具调用连接身份、路由记录、server 接收日志检查名称缓存和显示层
参数改变后仍沿用旧批准批准对象、最终参数、调用关联 ID定位批准与执行之间的变化点
跨 audience 凭据被接受有效 token 元数据、验证日志、资源返回定位 audience 校验或代理映射
session 被拒绝主体和会话绑定日志、正常对照记录该分支阻断,不推断其他权限正确
HTTP 200 但 JSON-RPC 错误或 isError完整响应类型与工具业务状态按协议错误、工具错误、业务拒绝分层
工具返回成功但无副作用记录调用 ID 与接收端状态标为执行结果待确认

输出目录使用 exports/general/<case>/mcp-boundary/,保存协议版本、工具目录、server 标识、参数差分、身份验证结果摘要、授权决定、响应和 canary 事件。

6. MCP 工具映射 ​

步骤工具能力边界
路由 MCP 信号kb_router(query="MCP token passthrough", board="general", track="red")检索技术文档
读取 OAuth 或关联篇kb_read_file按指定 board 和相对技术路径读取
检验目录、调用与授权目标 MCP 客户端/SDK 的测试入口必须完成目标协议的初始化、分页及响应处理;通用 GET 探测不能替代该流程
本篇离线试件本地 Python只验证合成策略矩阵,没有对真实 MCP 服务作出结论

蓝队关联:MCP 信任边界防御。

Apache-2.0 · 仅供授权环境下的学习与防御性研究使用 · 免责声明