## 为什么要集成而不是独立进程
上一版(29ad8aa)是独立进程 `plugins/zcode-mail-bridge/mcp/server.mjs`,
用 HTTP 调本网关。四条真实成本:
1. **工具语义有两份**。桥里的 read_inbox / send_mail 是**手抄**网关的,
抄错就是行为分叉 —— 已抓到两次:`connect_to_server` 只发
`X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
secret ⇒ secret-only 的 Agent 必然 400。
2. **鉴权与收窄要再实现一遍**。工作区收窄、会话收窄、冷静期、配额住在服务端。
3. **多一跳 + 多一个故障点**。
4. **接入端仍要装东西**(node + 桥 + 环境变量)。
现在:工具**包装现有 handler**,同一份代码、同一套鉴权与收窄;
接入端只填一个 URL。
## 传输与实现(用户裁定)
- **Streamable HTTP**(规范 2025-06-18):单端点 POST,通知回 202,
请求回 JSON-RPC。
- **包装 handler**(不是直调 repo):`newRequest` + `invoke` 造内部请求
交给 `handler.GetInbox` / `SendMail` / … 于是 `AgentMayReadSession`、
冷静期、配额、附件保护目录全部是同一条代码路径,不是复述。
- 手写零依赖 JSON-RPC(协议面只有 4 个方法),与本仓取向一致。
端点挂在 `AgentAuth` **之内**:必须与 /mail/send 同一套凭证,
否则就成了绕过收窄的旁门。
## 11 个工具,名字与参数与四桥逐字一致
`connect_to_server` 在这里只做一次真实读来确认连通性 —— 能调到它本身
就证明凭证已过(它是局内端点,不再需要 register)。
## ★ 端到端撞出并修掉的两个真 bug
**① `Tool.Run` 丢掉了身份**(本来写成 `context.Background()`)。
症状:每个工具调用都 Unauthorized,模型表现为「说连上了但读不到任何信」。
**② 路径参数没到位**:被包装的 handler 用 `chi.URLParam(r,"id")` 取 id,
而 `httptest.NewRequest` 造的请求**没过 chi 的路由** ⇒ `URLParam` 恒空
⇒ 任何带路径参数的工具都报「Invalid id」。
第②个的发现过程值得记:端到端测越权时,主人和越权者**都**返回
「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把**正确的收窄改松**;
做对照才看出是参数没到位。
修法两处:`withRouteParams` 注入 chi RouteContext;`invoke` 里**不能**再
`WithContext(ctx)` —— 那会覆盖掉刚注入的 RouteContext。
**③ 发现并暴露了会话越权漏洞**(同批,单独提交 095213b):
`AgentMayReadSession` 只比 `scope == target`,不问「你是不是参与方」,
而 session_id 由请求方给。对照实验 + 生产复核证实可读他人正文。
## 判据(13 格)
`internal/mcp/mcp_test.go`。真正在钉三件**只有集成才可能坏**的事:
1. MCP 不能成为越权旁门(工具参数里没有身份字段)。
2. 参数映射不许偷偷放宽/收紧(`attachment_ids` 被吞 ⇒ 附件静默不随信发出)。
3. 协议语义不许退化(工具失败必须 result+isError,不是 JSON-RPC error)。
`TestEveryErrorResponseCarriesID` 是被真 bug 逼出来的:曾用
`ID json.RawMessage` + `omitempty`,nil 时**整个 id 字段从 JSON 里消失**,
客户端会一直等这条的响应。遍历全部错误出口逐条验。
**变异验证**:
Run 丢身份 → 红 4
工具失败回 JSON-RPC error → 红 4
read_inbox 丢 workspace 收窄 → 红 1
id 泄露(tag+idPtr 同时失效) → 红 1 ★(真 bug 需两处同时失效,故两处防御都要留)
去掉 withRouteParams → 红 1
invoke 里加回 WithContext → 红 1
## 端到端(真实网关进程,临时库,备用端口 8199,不动生产)
未认证 /mcp → 401
错误密钥 → 401
initialize → 回显 2025-06-18
notifications/initialized→ 202 且无响应体
tools/list → 11 个,带 annotations 与 required
send_mail → read_inbox → mcp-peer 通过 MCP 读到对方发来的信
read_mail(带 session_id)→ 主人读到自己的信
## 未做
- 未删除旧桥 `plugins/zcode-mail-bridge/mcp/server.mjs`。它是 zcode 插件
清单里声明的入口(`.zcode-plugin/plugin.json` 的 mcpServers),删掉会破坏
该插件的组装。两者并存无害:桥仍走 HTTP,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
361 lines
12 KiB
Go
361 lines
12 KiB
Go
// Package mcp 把 MCP(Model Context Protocol)实现进网关本身。
|
||
//
|
||
// # 为什么在服务端而不是独立进程
|
||
//
|
||
// 早先的形状是 `plugins/zcode-mail-bridge/mcp/server.mjs`:一个独立进程,
|
||
// 用 HTTP 调本网关。这条路有四个真实成本:
|
||
//
|
||
// 1. **工具语义有两份**。桥里的 read_inbox / send_mail 是**手抄**网关的语义,
|
||
// 抄错就是行为分叉。实测已经踩到过一次:`connect_to_server` 只发
|
||
// `X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
|
||
// secret ⇒ secret-only 的 Agent 调它必然 400。
|
||
// 2. **鉴权与作用域要再实现一遍**。工作区收窄、会话收窄、冷静期、配额
|
||
// 这些规则住在服务端;独立进程拿不到,只能靠 HTTP 重走一遍。
|
||
// 3. **多一跳 + 多一个故障点**。宿主 → 桥进程 → HTTP → 网关。
|
||
// 4. **接入端仍要装东西**。本机装 node、装桥、配环境变量。
|
||
//
|
||
// 进服务端之后:工具**包装现有 handler**(见 tools.go),同一条代码路径、
|
||
// 同一套鉴权与收窄;宿主只需填一个 URL。
|
||
//
|
||
// # 传输:Streamable HTTP
|
||
//
|
||
// MCP 规范 2025-06-18 的传输:客户端 POST 一个 JSON-RPC 消息到单一端点,
|
||
// 服务端回 202(无输出)或一条 SSE 流。单条请求-响应场景最简单的是
|
||
// **直接回 JSON**(POST 一次拿到一个 JSON-RPC 响应),本实现这样做;
|
||
// 会把 Accept 头里的 `text/event-stream` 也认下来,返回 `Content-Type:
|
||
// application/json`(规范允许服务端在无待推送消息时如此)。
|
||
//
|
||
// 之所以不引 `github.com/modelcontextprotocol/go-sdk`:协议面只有四个方法,
|
||
// 而引 SDK 会带来一条依赖链;与本仓其余部分零依赖的取向一致(见
|
||
// server/go.mod)。手写让这一层成为可单测的纯函数。
|
||
package mcp
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
)
|
||
|
||
// 协议版本。客户端报的版本原样回显(见 handleMessage 的说明)。
|
||
const (
|
||
ProtocolVersion = "2025-06-18"
|
||
FallbackVersion = "2024-11-05"
|
||
|
||
ServerName = "agentmail"
|
||
ServerVersion = "1.0.0"
|
||
)
|
||
|
||
// JSON-RPC 错误码(只列本实现真的会返回的)。
|
||
const (
|
||
rpcParse = -32700
|
||
rpcInvalidRequest = -32600
|
||
rpcMethodNotFound = -32601
|
||
rpcInvalidParams = -32602
|
||
rpcInternal = -32603
|
||
)
|
||
|
||
// rpcMessage 是 JSON-RPC 消息。请求与响应共用(协议本身如此),故不分类型。
|
||
type rpcMessage struct {
|
||
JSONRPC string `json:"jsonrpc"`
|
||
// ID 是指针而不是 RawMessage:协议规定**每个响应都必须带 id**,
|
||
// 且解析失败时 id 必须是 JSON null。用 RawMessage 配 `omitempty` 时,
|
||
// nil 会让整个字段消失 —— 实测过一次(TestMalformedJSONGetsParseError
|
||
// 抓到的):响应里没有 id,客户端会一直等这条的响应。
|
||
//
|
||
// 指针的取舍:非空指针指向 RawMessage(可能是 `null`、数字、字符串);
|
||
// nil 指针表示「无 id」(通知)。
|
||
ID *json.RawMessage `json:"id"`
|
||
Method string `json:"method,omitempty"`
|
||
Params json.RawMessage `json:"params,omitempty"`
|
||
Result any `json:"result,omitempty"`
|
||
Error *rpcError `json:"error,omitempty"`
|
||
}
|
||
|
||
// nullID 是 JSON-RPC 规定的「id 为 null」(解析失败时用)。
|
||
var nullID = json.RawMessage("null")
|
||
|
||
// rawID 把指针化的 id 还原成 RawMessage(nil ⇒ nil)。
|
||
func rawID(p *json.RawMessage) json.RawMessage {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
return *p
|
||
}
|
||
|
||
type rpcError struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// toolCallParams 是 `tools/call` 的参数。
|
||
type toolCallParams struct {
|
||
Name string `json:"name"`
|
||
Arguments map[string]any `json:"arguments"`
|
||
}
|
||
|
||
// toolResult 是 `tools/call` 的结果。
|
||
//
|
||
// isError 的存在是**功能性的**:MCP 的约定是工具执行失败回 result +
|
||
// isError:true,而不是 JSON-RPC error —— 后者模型只看到"协议错误",
|
||
// 拿不到失败原因就没法改道(换个 attachment_id 重试之类)。
|
||
type toolResult struct {
|
||
Content []content `json:"content"`
|
||
IsError bool `json:"isError,omitempty"`
|
||
}
|
||
|
||
type content struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text,omitempty"`
|
||
}
|
||
|
||
// textResult 造一条成功结果。
|
||
func textResult(s string) toolResult {
|
||
return toolResult{Content: []content{{Type: "text", Text: s}}}
|
||
}
|
||
|
||
// errorResult 造一条失败结果(**不是** JSON-RPC error)。
|
||
func errorResult(format string, a ...any) toolResult {
|
||
return toolResult{
|
||
Content: []content{{Type: "text", Text: fmt.Sprintf(format, a...)}},
|
||
IsError: true,
|
||
}
|
||
}
|
||
|
||
// idPtr 把 nil 归一成「显式的 JSON null」—— 响应**必须**带 id 字段。
|
||
func idPtr(id json.RawMessage) *json.RawMessage {
|
||
if id == nil {
|
||
return &nullID
|
||
}
|
||
return &id
|
||
}
|
||
|
||
func result(id json.RawMessage, v any) *rpcMessage {
|
||
return &rpcMessage{JSONRPC: "2.0", ID: idPtr(id), Result: v}
|
||
}
|
||
|
||
func failure(id json.RawMessage, code int, format string, a ...any) *rpcMessage {
|
||
return &rpcMessage{
|
||
JSONRPC: "2.0",
|
||
ID: idPtr(id),
|
||
Error: &rpcError{Code: code, Message: fmt.Sprintf(format, a...)},
|
||
}
|
||
}
|
||
|
||
// Tool 是一次 MCP 工具调用。
|
||
type Tool interface {
|
||
// Schema 声明工具名、说明与入参 JSON Schema。
|
||
Schema() ToolSchema
|
||
// Run 执行。ctx 是**当前请求的 context**,里面带着 AgentAuth 放进来的
|
||
// 身份(middleware.AgentNameKey)。
|
||
//
|
||
// ★ 为什么 ctx 必须显式传进来(而不是在实现里 context.Background()):
|
||
// 身份就住在这个 ctx 里。丢掉它 ⇒ 每个工具调用都 Unauthorized,
|
||
// 而症状是「模型说连上了但读不到任何信」—— 很难当场归因。
|
||
// 这条是被判据逼出来的(TestReadInboxRequiresWorkspaceSameAsHTTP
|
||
// 先是报 Unauthorized 才暴露出来)。
|
||
Run(ctx context.Context, args map[string]any) (string, error)
|
||
}
|
||
|
||
// ToolSchema 是 tools/list 里每个条目的形状。
|
||
//
|
||
// Annotations 是 MCP 规范里的提示字段(readOnlyHint / destructiveHint 等)。
|
||
// 本实现**透传**它:部分宿主据此算风险等级并在 plan 档下放行非破坏性工具,
|
||
// 漏传的后果不是"少个提示"而是工具在该档下全被拒。
|
||
type ToolSchema struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
InputSchema map[string]any `json:"inputSchema"`
|
||
Annotations map[string]any `json:"annotations,omitempty"`
|
||
}
|
||
|
||
// Server 是 MCP 端点。
|
||
type Server struct {
|
||
tools map[string]Tool
|
||
log *log.Logger
|
||
}
|
||
|
||
// NewServer 造一个端点。
|
||
func NewServer(logger *log.Logger) *Server {
|
||
if logger == nil {
|
||
logger = log.Default()
|
||
}
|
||
return &Server{tools: map[string]Tool{}, log: logger}
|
||
}
|
||
|
||
// Register 注册一个工具。同名时后者覆盖前者(测试里常用)。
|
||
func (s *Server) Register(t Tool) {
|
||
s.tools[t.Schema().Name] = t
|
||
}
|
||
|
||
// RegisterAll 批量注册。
|
||
func (s *Server) RegisterAll(ts ...Tool) {
|
||
for _, t := range ts {
|
||
s.Register(t)
|
||
}
|
||
}
|
||
|
||
// ToolCount 供测试与 /mcp 自述用。
|
||
func (s *Server) ToolCount() int { return len(s.tools) }
|
||
|
||
// HandleHTTP 处理一次 POST。
|
||
//
|
||
// 认证在**外层**(main.go 把 middleware.AgentAuth 挂在这条路由上)——
|
||
// 与普通 Agent 端点同一套凭证(Bearer 密钥或 name/secret),
|
||
// 所以 MCP 不能成为绕过既有鉴权与收窄的后门。
|
||
func (s *Server) HandleHTTP(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
w.Header().Set("Allow", "POST")
|
||
writeJSON(w, http.StatusMethodNotAllowed, failure(nil, rpcInvalidRequest, "MCP 端点只接受 POST"))
|
||
return
|
||
}
|
||
// 限制请求体:工具调用都是小 JSON,附件走独立的 /attachments 端点。
|
||
// 1MB 足够,且挡住"把整个文件塞进 JSON"的用法。
|
||
const maxBody = 1 << 20
|
||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, failure(nil, rpcParse, "读请求体失败:%v", err))
|
||
return
|
||
}
|
||
|
||
var msg rpcMessage
|
||
if err := json.Unmarshal(body, &msg); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, failure(nil, rpcParse, "不是合法的 JSON"))
|
||
return
|
||
}
|
||
if msg.JSONRPC != "2.0" {
|
||
writeJSON(w, http.StatusBadRequest, failure(rawID(msg.ID), rpcInvalidRequest, `jsonrpc 字段必须是 "2.0"`))
|
||
return
|
||
}
|
||
|
||
out := s.handleMessage(&msg, r)
|
||
if out == nil {
|
||
// 通知(无 id):没有响应体。按规范回 202。
|
||
w.WriteHeader(http.StatusAccepted)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// handleMessage 分发一条消息,返回要写回的响应;通知返回 nil。
|
||
//
|
||
// 抽出来是为了能**不经过 HTTP** 单测(httptest 之外也能穷举协议分支)。
|
||
func (s *Server) handleMessage(msg *rpcMessage, r *http.Request) *rpcMessage {
|
||
// 通知没有 id。回了响应,客户端会把响应与请求错配,后续调用全乱。
|
||
isNotification := msg.ID == nil
|
||
id := rawID(msg.ID)
|
||
|
||
switch msg.Method {
|
||
case "initialize":
|
||
if isNotification {
|
||
return nil
|
||
}
|
||
var p struct {
|
||
ProtocolVersion string `json:"protocolVersion"`
|
||
}
|
||
_ = json.Unmarshal(msg.Params, &p)
|
||
// 回显客户端给的版本:不认识的也回显,交由客户端决定是否降级。
|
||
// 自作主张改成我们的版本会让客户端以为协商成功而按新语义调用。
|
||
version := p.ProtocolVersion
|
||
if version == "" {
|
||
version = FallbackVersion
|
||
}
|
||
return result(id, map[string]any{
|
||
"protocolVersion": version,
|
||
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
|
||
"serverInfo": map[string]any{"name": ServerName, "version": ServerVersion},
|
||
})
|
||
|
||
case "notifications/initialized", "initialized":
|
||
return nil // 纯通知
|
||
|
||
case "ping":
|
||
if isNotification {
|
||
return nil
|
||
}
|
||
return result(id, map[string]any{})
|
||
|
||
case "tools/list":
|
||
if isNotification {
|
||
return nil
|
||
}
|
||
list := make([]ToolSchema, 0, len(s.tools))
|
||
for _, t := range s.tools {
|
||
list = append(list, t.Schema())
|
||
}
|
||
// 稳定顺序:map 迭代随机会让客户端每次刷新看到不同排列。
|
||
sortTools(list)
|
||
return result(id, map[string]any{"tools": list})
|
||
|
||
case "tools/call":
|
||
if isNotification {
|
||
return nil
|
||
}
|
||
var p toolCallParams
|
||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||
return failure(id, rpcInvalidParams, "tools/call 参数不是合法 JSON:%v", err)
|
||
}
|
||
if p.Name == "" {
|
||
return failure(id, rpcInvalidParams, "tools/call 缺少 name")
|
||
}
|
||
t, ok := s.tools[p.Name]
|
||
if !ok {
|
||
// 未知工具名:回 INVALID_PARAMS 而不是「执行失败」——
|
||
// 前者说"你叫错了",后者说"我试了但失败",模型的反应不同。
|
||
return failure(id, rpcInvalidParams, "没有名为 %s 的工具(可用:%s)", p.Name, strings.Join(s.toolNames(), ", "))
|
||
}
|
||
args := p.Arguments
|
||
if args == nil {
|
||
args = map[string]any{} // 缺 arguments 当空对象,不抛错
|
||
}
|
||
text, err := t.Run(r.Context(), args)
|
||
if err != nil {
|
||
s.log.Printf("[mcp] 工具 %s 失败:%v", p.Name, err)
|
||
// 失败走 result + isError,**不是** JSON-RPC error(见 toolResult 注释)。
|
||
return result(id, errorResult("工具 %s 执行失败:%s", p.Name, err.Error()))
|
||
}
|
||
return result(id, textResult(text))
|
||
|
||
default:
|
||
if isNotification {
|
||
return nil
|
||
}
|
||
return failure(id, rpcMethodNotFound, "不支持的方法 %q", msg.Method)
|
||
}
|
||
}
|
||
|
||
// toolNames 返回已注册工具名(错误文案里提示模型可用集合)。
|
||
func (s *Server) toolNames() []string {
|
||
names := make([]string, 0, len(s.tools))
|
||
for n := range s.tools {
|
||
names = append(names, n)
|
||
}
|
||
sortStrings(names)
|
||
return names
|
||
}
|
||
|
||
func sortTools(list []ToolSchema) {
|
||
for i := 1; i < len(list); i++ {
|
||
for j := i; j > 0 && list[j].Name < list[j-1].Name; j-- {
|
||
list[j], list[j-1] = list[j-1], list[j]
|
||
}
|
||
}
|
||
}
|
||
|
||
func sortStrings(s []string) {
|
||
for i := 1; i < len(s); i++ {
|
||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||
s[j], s[j-1] = s[j-1], s[j]
|
||
}
|
||
}
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|