封面信息图

上周五下午运维群里突然弹出一条告警,测试环境的对账数据库被某个排障 Agent 执行了清理操作。顺着审计日志往上捋,发现是实习生在写 MCP(Model Context Protocol)工具注册时,把原本只开放给 DBA 运维机器人的底层数据重置接口,直接暴露在了公共 MCP Server 的工具列表里。那个排障 Agent 在解析报错日志后产生幻觉,误以为清理表缓存能恢复服务,直接触发了 tools/call。

这起事故暴露了一个致命隐患:很多团队在内网落地 MCP 架构时,图省事把 MCP 网关当成了裸透传管道。无论是基于 SSE 还是 Stdio 传输,只要 Agent 和 Server 握手成功,Agent 就能毫无阻碍地调用该 Server 下挂载的全部工具。但在企业内部,不同业务线的 Agent 权限天差地别:客服 Agent 只能查订单物流,售后 Agent 可以提交退款申请,财务 Agent 才能执行资金转账。如果不在网关层构筑硬隔离的鉴权体系,Prompt 注入或者模型幻觉迟早会打穿核心内网。

权限隔离的核心矛盾:JSON-RPC 协议下的鉴权空白

官方的 MCP 规范基于 JSON-RPC 2.0,重点定义了上下文传输、资源挂载(Resources)、提示模板(Prompts)以及工具调用(Tools)的消息格式。但这套规范把安全责任完全推给了底层传输层和业务宿主。

直接套用传统 Web API 的网关鉴权会遇到三个具体痛点:

  1. 会话级连接与单次调用的权限错配:Agent 与 MCP 网关通常维持长生命周期的双向 SSE 或 WebSocket 连接。握手时的身份合法,不代表后续每个 tools/call 都有权访问底层系统。
  2. 工具颗粒度的动态裁剪:客服 Agent 初始化时调用 tools/list,网关必须根据其身份动态裁剪返回的工具列表。不能让客服 Agent 看到 refund_execute 工具,否则大模型的系统提示词会被无关工具的 JSON Schema 撑爆,增加推理成本不说,还极大增加了误调几率。
  3. 内网微服务身份传递丢失:MCP 网关代理工具调用时,必须把原始 Agent 的身份凭证(Caller Identity)、租户 ID 以及操作人链条完整下发给真实的后端微服务,否则内网系统的审计与风控形同虚设。

架构设计:双层 JWT + 工具白名单位图

我们最终落地的方案由“短期 OAuth2 令牌签发”与“网关层位图权限校验”两部分构成。

整个调用链分为三步:
Agent 启动前,向内部 OAuth2 服务出示自身的 AppKey 与 Secret,换取一个有效期仅为 15 分钟的短期 JWT。该 Token 的 Payload 里封装着该 Agent 的身份唯一标识、所属业务域以及被授权的工具 Scope 列表(如 tools:order:read, tools:refund:request)。

MCP 网关在处理两类核心 JSON-RPC 请求时进行拦截控制:

  • 拦截 tools/list:网关解析 JWT,根据其拥有的 Scope 过滤后台 MCP Server 注册的完整工具池,只将子集序列化后返回给 Agent。
  • 拦截 tools/call:网关校验请求调用的工具是否在当前 Token 的有效白名单内。如果越权,直接在网关层截断并返回 JSON-RPC 错误码 -32001 (Unauthorized Tool Call),根本不把请求投递到底层微服务。

Go 1.27.1 网关拦截器实战

在网关拦截链路上,高并发下的校验性能是关键。Go 1.27.1 对小于 80 字节的小对象分配性能做了深度优化,开销降低了近 30%,同时支持通用泛型方法。我们在网关拦截器中使用泛型方法统一提取不同传输协议中的认证上下文,并结合 Go 1.26 的 new(expr) 极简指针初始化,以极低的 GC 负担完成鉴权校验。

以下是 MCP 网关核心鉴权拦截器的 Go 语言实现:

package mcpauth

import (
	"context"
	"crypto/rsa"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// MCPRPCRequest 映射 MCP 协议基础调用载荷
type MCPRPCRequest struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      any             `json:"id"`
	Method  string          `json:"method"`
	Params  MCPRPCParams    `json:"params"`
}

type MCPRPCParams struct {
	Name      string         `json:"name,omitempty"`      // tools/call 中的工具名
	Arguments map[string]any `json:"arguments,omitempty"` // 工具入参
}

// AgentClaims 封装 Agent 身份与授权工具集
type AgentClaims struct {
	AgentID       string   `json:"aid"`
	Role          string   `json:"rol"`
	TenantID      string   `json:"tid"`
	AllowedScopes []string `json:"scp"`
	jwt.RegisteredClaims
}

// GatewayInterceptor 负责 MCP 鉴权与调用拦截
type GatewayInterceptor struct {
	verifyKey     *rsa.PublicKey
	toolScopeMap  map[string]string // tool_name -> required_scope
}

func NewGatewayInterceptor(pubKey *rsa.PublicKey, toolScopes map[string]string) *GatewayInterceptor {
	return &GatewayInterceptor{
		verifyKey:    pubKey,
		toolScopeMap: toolScopes,
	}
}

// AuthenticateToken 统一提取并校验 JWT 凭证
func (g *GatewayInterceptor) AuthenticateToken(tokenStr string) (*AgentClaims, error) {
	token, err := jwt.ParseWithClaims(tokenStr, &AgentClaims{}, func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
		}
		return g.verifyKey, nil
	})

	if err != nil || !token.Valid {
		return nil, errors.New("mcp: invalid or expired bearer token")
	}

	claims, ok := token.Claims.(*AgentClaims)
	if !ok {
		return nil, errors.New("mcp: malformed claims payload")
	}

	return claims, nil
}

// AuthorizeToolCall 利用 Go 1.27.1 方法级泛型,统一校验各类 RPC 载荷的工具权限
func (g *GatewayInterceptor) AuthorizeToolCall[T ~string](claims *AgentClaims, toolName T) error {
	name := string(toolName)
	requiredScope, exists := g.toolScopeMap[name]
	if !exists {
		// 未登记的工具默认禁止任何人调用,遵循最小权限原则
		return fmt.Errorf("mcp: tool %q is not registered in security catalog", name)
	}

	// 遍历 Agent 拥有的 scope 进行前缀匹配
	for _, scope := range claims.AllowedScopes {
		if scope == "*" || scope == requiredScope {
			return nil
		}
		// 支持通配符匹配,例如 tools:order:*
		if strings.HasSuffix(scope, ":*") {
			prefix := strings.TrimSuffix(scope, "*")
			if strings.HasPrefix(requiredScope, prefix) {
				return nil
			}
		}
	}

	return fmt.Errorf("mcp: agent %s unauthorized for tool %q (needs %s)", claims.AgentID, name, requiredScope)
}

// HandleRPC 拦截完整的 MCP 请求链路
func (g *GatewayInterceptor) HandleRPC(ctx context.Context, token string, req *MCPRPCRequest) error {
	claims, err := g.AuthenticateToken(token)
	if err != nil {
		return err
	}

	// 仅拦截 tools/call 动作,其他基础握手如 ping、initialize 走全局限流
	if req.Method == "tools/call" {
		if err := g.AuthorizeToolCall(claims, req.Params.Name); err != nil {
			return err
		}
	}

	return nil
}

生产避坑:长连接 Token 过期与工具权限热更新

在实际压测与上线排障中,有两个暗坑差点把系统拖垮:

第一,长连接中间 Token 自然过期。
Agent 与 MCP 网关建立 SSE 长连接后,对话可能会持续数小时。如果我们在每个 tools/call 请求来临时严格校验原始建立连接时的 JWT,在 15 分钟后所有请求都会失败。
错误的做法是把 JWT 有效期调长到 24 小时——这违背了短期凭证的安全初衷。
正确的做法是引入客户端静默刷新机制:MCP 协议客户端支持自定义自定义消息。当网关发现连接上的 JWT 剩余有效期低于 2 分钟时,通过通道向客户端下发 notifications/token_refresh_required 事件。Agent 内部的 MCP SDK 收到通知后,异步请求 OAuth2 服务获取新 Token,并通过 custom/refresh_auth 报文将新 Token 绑定到当前长连接上下文中。

第二,跨服务调用时的透传追踪。
MCP 网关放行 tools/call 之后,底层负责执行具体业务逻辑的微服务(例如库存服务、支付服务)不能直接信任 MCP 网关的 IP。我们在网关打向下游 gRPC 或 HTTP 内部接口时,使用了网关私钥二次签发一个仅 30 秒有效期的“委派调用 Token”(Delegation Token),在 Header 中明确塞入:

  • X-MCP-Agent-ID: 发起调用的 Agent 编码
  • X-MCP-Trace-ID: 业务链路全流程 ID
  • X-MCP-Tool-Name: 执行的工具函数名

下游核心业务系统收到请求后,先验证这个内部专用的二次签名,并把 Agent-ID 记录到数据库审计行上。这样即使将来某一天 Agent 逻辑出现极端异常,财务与安全团队也能在三秒钟内精确定位是哪个 Agent 的哪次会话触发了哪笔退款,而不是抓着运维和网关开发通宵排查。

把这套鉴权网关部署上线后,全站 18 个不同等级的业务 Agent 接入同一个 MCP 集群,不仅没再发生过一次越权调用事故,还因为动态工具裁剪让大模型的单次 Prompt 平均缩减了近 900 Token,真正在安全和成本上都拿到了实打实的结果。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐