feat(mcp): MCP 集成进网关本体 —— POST /api/v1/mcp(Streamable HTTP)

## 为什么要集成而不是独立进程

上一版(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,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
This commit is contained in:
2026-10-02 13:28:07 +08:00
parent 095213b981
commit 457d1608f0
6 changed files with 1921 additions and 0 deletions

View File

@ -0,0 +1,360 @@
// 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)
}