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

@ -14,6 +14,7 @@ import (
"github.com/agentmail/gateway/internal/config" "github.com/agentmail/gateway/internal/config"
"github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/handler" "github.com/agentmail/gateway/internal/handler"
"github.com/agentmail/gateway/internal/mcp"
"github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/push" "github.com/agentmail/gateway/internal/push"
"github.com/agentmail/gateway/internal/repo" "github.com/agentmail/gateway/internal/repo"
@ -73,6 +74,13 @@ func main() {
scheduler.Start() scheduler.Start()
defer scheduler.Stop() defer scheduler.Stop()
// MCP 端点(Streamable HTTP)。工具**包装现有 handler**,所以工具语义
// 与 /api/v1/agent/* 完全同一份代码 —— 不存在第二套收窄或配额逻辑。
mcpServer := mcp.NewServer(nil)
mcp.NewTools().RegisterAll(mcpServer)
mcpHandler := mcpServer.HandleHTTP
log.Printf("MCP 端点就绪:POST /api/v1/mcp(%d 个工具)", mcpServer.ToolCount())
r := chi.NewRouter() r := chi.NewRouter()
r.Use(chimw.Logger) r.Use(chimw.Logger)
@ -116,6 +124,16 @@ func main() {
// ---- Agent 侧(X-Agent-Name + X-Agent-Secret) ---- // ---- Agent 侧(X-Agent-Name + X-Agent-Secret) ----
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.AgentAuth) r.Use(middleware.AgentAuth)
// ★ 2026-10-02:MCP 端点。挂在 AgentAuth **之内**是刻意的 ——
// 它必须与 /mail/send、/mail/inbox 走同一套凭证,否则就成了
// 绕过工作区收窄与会话收窄的旁门(那正是 2026-10-02 修掉的
// 越权形状)。工具内部包装的就是下面这些 handler。
//
// 这样接入端只需填一个 URL(Streamable HTTP 的 POST),不用装插件、
// 不用起进程、不用配环境变量。
r.Post("/mcp", mcpHandler)
r.Post("/agent/heartbeat", handler.HeartbeatAgent) r.Post("/agent/heartbeat", handler.HeartbeatAgent)
r.Post("/mail/send", handler.SendMail) r.Post("/mail/send", handler.SendMail)
r.Get("/mail/inbox", handler.GetInbox) r.Get("/mail/inbox", handler.GetInbox)

View File

@ -0,0 +1,541 @@
package mcp
// MCP 端点的判据。
//
// # 这些判据真正在钉什么
//
// MCP 工具**包装现有 handler**,所以它成立的唯一依据是「与 HTTP 端点
// 同一套语义」。那就不必重新测一遍业务规则(那些在 handler 自己的测试里),
// 而要钉三件**只有集成才可能坏**的事:
//
// 1. **MCP 不能成为越权旁门**。工具参数里没有身份字段;身份只能来自
// AgentAuth 放进的 context。若某处改成从参数取身份,任何人都能冒充
// 别的 Agent —— 这正是 2026-10-02 修掉的 `AgentMayReadSession`
// `if scope == nil { return true }` 那种失败模式(默认放行)。
// 2. **参数映射不许偷偷放宽/收紧**。例如 send_mail 的 `attachment_ids`
// 若被吞掉,附件会静默不随信发出;workspace 若被丢掉,GetInbox 会 400。
// 3. **协议语义不许退化**。工具失败必须回 result+isError 而不是
// JSON-RPC error(否则模型只看到「协议错误」,拿不到原因就没法改道)。
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
)
func setupDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), filepath.Join(dir, "mcp-test.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(db.Close)
}
// newTestServer 起一个挂了全部工具的端点。
func newTestServer() *Server {
s := NewServer(log.New(os.Stderr, "", 0))
NewTools().RegisterAll(s)
return s
}
// call 走完整 HTTP 路径调一个工具(这样才是端到端,不是直接调 Run)。
func call(t *testing.T, s *Server, agent, tool string, args map[string]any) (int, *rpcMessage) {
t.Helper()
if args == nil {
args = map[string]any{}
}
payload, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args},
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
r := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(string(payload)))
if agent != "" {
r = r.WithContext(context.WithValue(r.Context(), middleware.AgentNameKey, agent))
}
rec := httptest.NewRecorder()
s.HandleHTTP(rec, r)
var msg rpcMessage
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
return rec.Code, &msg
}
// toolText 取出 tools/call 的文本内容。
func toolText(t *testing.T, msg *rpcMessage) (string, bool) {
t.Helper()
raw, err := json.Marshal(msg.Result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
var tr toolResult
if err := json.Unmarshal(raw, &tr); err != nil {
t.Fatalf("result 不是 toolResult:%v", err)
}
if len(tr.Content) == 0 {
return "", tr.IsError
}
return tr.Content[0].Text, tr.IsError
}
// seedAgentAndMail 建一个 Agent 与一封寄给它的邮件。
func seedAgentAndMail(t *testing.T, agent, workspace, subject string) (string, string) {
t.Helper()
ctx := context.Background()
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO agents (agent_name, secret, platform, default_rounds) VALUES ($1, 'x', 'test', 50)`,
agent); err != nil {
t.Fatalf("seed agent: %v", err)
}
fromName, alias := "gui-lab", "s1"
mailID := repoInsertMail(t, ctx, agent, fromName, workspace, alias, subject)
var sessionID string
if err := db.DB.QueryRowContext(ctx,
`SELECT session_id FROM mails WHERE mail_id=$1`, mailID).Scan(&sessionID); err != nil {
t.Fatalf("取 session_id: %v", err)
}
_ = fromName
return mailID, fromName
}
// sessionOf 取一封信所属的会话。
func sessionOf(t *testing.T, mailID string) string {
t.Helper()
var id string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT session_id FROM mails WHERE mail_id=$1`, mailID).Scan(&id); err != nil {
t.Fatalf("sessionOf: %v", err)
}
return id
}
// ---- 协议层 ----
func TestInitializeEchoesClientVersion(t *testing.T) {
s := newTestServer()
payload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`
r := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(payload))
rec := httptest.NewRecorder()
s.HandleHTTP(rec, r)
var msg rpcMessage
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
if msg.Error != nil {
t.Fatalf("initialize 不该报错:%v", msg.Error)
}
res := msg.Result.(map[string]any)
if got := res["protocolVersion"]; got != "2025-06-18" {
t.Errorf("协议版本应回显客户端给的 2025-06-18,收到 %v", got)
}
// 反向对照:缺版本时用回退值,不崩
payload = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
rec = httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(payload)))
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
if msg.Result.(map[string]any)["protocolVersion"] == nil {
t.Error("缺 protocolVersion 时应回退一个值,而不是省略该字段")
}
}
func TestNotificationsGetNoResponse(t *testing.T) {
s := newTestServer()
// 没有 id = 通知。回了响应,客户端会把响应与请求错配。
for _, m := range []string{
`{"jsonrpc":"2.0","method":"notifications/initialized"}`,
`{"jsonrpc":"2.0","method":"tools/list"}`,
`{"jsonrpc":"2.0","method":"ping"}`,
} {
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(m)))
if rec.Code != http.StatusAccepted {
t.Errorf("通知 %s:期望 202,收到 %d(并带响应体 %q)", m, rec.Code, rec.Body.String())
}
}
}
func TestToolsListIsStableAndCarriesAnnotations(t *testing.T) {
s := newTestServer()
var names []string
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)))
var msg rpcMessage
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
list := msg.Result.(map[string]any)["tools"].([]any)
names = names[:0]
for _, it := range list {
names = append(names, it.(map[string]any)["name"].(string))
}
if i == 0 {
// annotations 必须透传:宿主据此算风险等级并在 plan 档放行。
// 漏传的后果不是「少个提示」,而是工具在该档下全被拒。
found := false
for _, it := range list {
m := it.(map[string]any)
if m["name"] == "read_inbox" {
if _, ok := m["annotations"]; !ok {
t.Error("read_inbox 缺 annotations")
}
found = true
}
}
if !found {
t.Error("tools/list 里没有 read_inbox")
}
}
}
// 顺序必须稳定:map 迭代随机会让客户端每次刷新看到不同排列
if len(names) == 0 {
t.Fatal("tools/list 为空")
}
for j := 1; j < len(names); j++ {
if names[j] < names[j-1] {
t.Fatalf("工具顺序不稳定:%v", names)
}
}
}
func TestUnknownToolIsInvalidParamsNotExecutionFailure(t *testing.T) {
s := newTestServer()
_, msg := call(t, s, "a", "no_such_tool", nil)
if msg.Error == nil {
t.Fatal("未知工具名应回 JSON-RPC error")
}
if msg.Error.Code != rpcInvalidParams {
t.Errorf("期望 INVALID_PARAMS(%d),收到 %d", rpcInvalidParams, msg.Error.Code)
}
// 错误文案要告诉模型可用集合,否则它只能瞎试
if !strings.Contains(msg.Error.Message, "read_inbox") {
t.Errorf("未知工具的错误应列出可用工具,收到 %q", msg.Error.Message)
}
}
func TestToolFailureIsResultIsErrorNotRPCError(t *testing.T) {
setupDB(t)
s := newTestServer()
// 不存在的 Agent + 非法 workspace ⇒ handler 报错。
// 关键不是失败本身,而是失败**怎么**回。
_, msg := call(t, s, "ghost", "read_inbox", map[string]any{"workspace": "not-absolute"})
if msg.Error != nil {
t.Fatalf("工具失败不该回 JSON-RPC error(模型就看不到原因了),收到 %v", msg.Error)
}
text, isErr := toolText(t, msg)
if !isErr {
t.Error("失败时必须带 isError:true")
}
if text == "" {
t.Error("失败时必须给出原因文本")
}
}
func TestMethodNotAllowedOnNonPost(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET /mcp 应 405,收到 %d", rec.Code)
}
}
func TestMalformedJSONGetsParseError(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(`{oops`)))
if !strings.Contains(rec.Body.String(), `"id":null`) {
t.Errorf("解析错误响应必须带 id:null,收到 %q", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), fmt.Sprintf("%d", rpcParse)) {
t.Errorf("非法 JSON 应回 PARSE_ERROR(%d),收到 %q", rpcParse, rec.Body.String())
}
}
// ---- 工具语义与 HTTP 端点同源 ----
func TestReadInboxRequiresWorkspaceSameAsHTTP(t *testing.T) {
setupDB(t)
seedAgentAndMail(t, "probe", "/tmp/ws", "一封信")
s := newTestServer()
// 不带 workspace ⇒ 400(这是 GetInbox 的既有语义,包装后必须一致)
_, msg := call(t, s, "probe", "read_inbox", map[string]any{})
if msg.Error != nil {
t.Fatalf("工具失败不该是协议错误:%v", msg.Error)
}
text, isErr := toolText(t, msg)
if !isErr {
t.Errorf("缺 workspace 时 read_inbox 必须失败,收到 %q", text)
}
if !strings.Contains(text, "workspace") {
t.Errorf("错误文案应说清是 workspace 的问题,收到 %q", text)
}
}
func TestReadInboxScopedToOwnWorkspace(t *testing.T) {
setupDB(t)
ctx := context.Background()
// 同一个 Agent 在两个工作区各有信;按工作区收窄后只能看到自己那个的
seedAgentAndMail(t, "probe", "/tmp/wsA", "A 区的信")
repoInsertMail(t, ctx, "probe", "gui-lab", "/tmp/wsB", "sB", "B 区的信")
s := newTestServer()
_, msg := call(t, s, "probe", "read_inbox", map[string]any{"workspace": "/tmp/wsA"})
text, isErr := toolText(t, msg)
if isErr {
t.Fatalf("read_inbox 失败:%s", text)
}
if !strings.Contains(text, "A 区的信") {
t.Errorf("应看到 A 区的信,收到 %q", text)
}
if strings.Contains(text, "B 区的信") {
t.Errorf("★ 收窄失效:看到了别的工作区的信(B 区的信),输出 %q", text)
}
}
func TestAgentCannotReadOthersMail(t *testing.T) {
setupDB(t)
ctx := context.Background()
// 别人的信
otherMailID, _ := seedAgentAndMail(t, "victim", "/tmp/ws", "别人的信")
s := newTestServer()
// 攻击者身份,读别人的信
_, msg := call(t, s, "attacker", "read_mail", map[string]any{"mail_id": otherMailID})
text, isErr := toolText(t, msg)
if !isErr {
t.Errorf("★ 越权:attacker 读到了 victim 的邮件,输出 %q", text)
}
if strings.Contains(text, "别人的信") {
t.Errorf("★ 越权:泄露了别人的正文 %q", text)
}
_ = ctx
}
func TestToolArgsCannotForgeIdentity(t *testing.T) {
setupDB(t)
// 传 identity 相关的参数不应影响权限判定
s := newTestServer()
_, msg := call(t, s, "attacker", "read_inbox", map[string]any{
"workspace": "/tmp/ws",
"agent_name": "victim",
"X-Agent-Name": "victim",
"from": "victim",
})
// 受害者不在这个工作区/或不存在时应当看到「空」而不是别人的信
text, _ := toolText(t, msg)
if strings.Contains(text, "别人的信") {
t.Errorf("参数里的身份字段不该影响鉴权,收到 %q", text)
}
}
// ---- 参数映射 ----
func TestAttachmentIDsSurviveMapping(t *testing.T) {
setupDB(t)
_, _ = seedAgentAndMail(t, "probe", "/tmp/ws", "占位")
s := newTestServer()
// 用受配额的发信路径发一封带附件的信(附件 id 传下去)
_, msg := call(t, s, "probe", "send_mail", map[string]any{
"to": "gui-lab@/tmp.s1",
"subject": "带附件",
"body": "见附件",
"attachment_ids": []any{"att-does-not-exist"},
})
text, isErr := toolText(t, msg)
if !isErr {
t.Fatalf("传了不存在的附件 id,send_mail 理应失败,收到 %q", text)
}
// 附件 id 真被记到那封信上
// 发信时被拒的 attachment_id 必须在错误里出现(说明映射到了、校验才起效)。
// 换一个不存在的 id 会被 SendMail 拒绝,而拒绝原因正是参数映射是否生效的直接证据。
if !strings.Contains(text, "attachment") {
t.Errorf("★ attachment_ids 似乎没被映射过去(错误里没提附件):%s", text)
}
}
func TestMissingArgumentsTreatedAsEmpty(t *testing.T) {
setupDB(t)
seedAgentAndMail(t, "probe", "/tmp/ws", "信")
s := newTestServer()
// suggest_address 无参 ⇒ 返回候选名,不该崩
_, msg := call(t, s, "probe", "suggest_address", nil)
if msg.Error != nil {
t.Fatalf("无参 suggest_address 不该是协议错误:%v", msg.Error)
}
_, isErr := toolText(t, msg)
if isErr {
t.Errorf("无参 suggest_address 不该失败")
}
}
func TestToolCountAndNoExecutionTools(t *testing.T) {
s := newTestServer()
// 11 个邮件工具,不含任何"会动机器"的工具
if got := s.ToolCount(); got != 11 {
t.Errorf("工具数应为 11(只读+收发邮件),收到 %d", got)
}
for _, name := range []string{"run_command", "write_file", "exec", "shell"} {
if _, ok := s.tools[name]; ok {
t.Errorf("★ 通用 MCP 面不得提供执行类工具 %q", name)
}
}
}
// ---- 辅助 ----
func repoInsertMail(t *testing.T, ctx context.Context, to, from, workspace, alias, subject string) string {
t.Helper()
sessionID := repoEnsureSession(t, ctx, to, from, workspace, alias)
var mailID string
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, status)
VALUES ($1,$2,$3,$4,'正文',$5) RETURNING mail_id`,
sessionID, from, to, subject, "unread").Scan(&mailID)
if err != nil {
t.Fatalf("insert mail: %v", err)
}
return mailID
}
func repoEnsureSession(t *testing.T, ctx context.Context, to, from, workspace, alias string) string {
t.Helper()
t.Helper()
var sessionID string
// 收窄走 s.workspace(见 repo.workspaceScope),所以工作区建会话时就得给对
err := db.DB.QueryRowContext(ctx,
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent) VALUES ($1,'t','active',$2,$3)
RETURNING session_id`, alias, workspace, to).Scan(&sessionID)
if err != nil {
t.Fatalf("insert session: %v", err)
}
_ = from
return sessionID
}
// repoInsertAttachment 造一个附件。upload_attachment 走 blob 存储,
// 这里只需要一个**已存在**的 attachment_id 来验证参数映射。
func repoInsertAttachment(t *testing.T, mailID string) string {
t.Helper()
var id string
err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
VALUES ($1,'probe','a.txt','text/plain',12,'deadbeef') RETURNING attachment_id`, mailID).Scan(&id)
if err != nil {
t.Fatalf("insert attachment: %v", err)
}
return id
}
// repoAttachmentIDsOfMail 取一封信挂着的附件 id(attachments.mail_id)。
func repoAttachmentIDsOfMail(t *testing.T, mailID string) string {
t.Helper()
var ids string
err := db.DB.QueryRowContext(context.Background(),
`SELECT COALESCE(group_concat(attachment_id),'') FROM attachments WHERE mail_id=$1`, mailID).Scan(&ids)
if err != nil {
return ""
}
return ids
}
var _ = repo.ListInboxScoped // 保持 import(工具链会用到 handler,repo 只在测试里用到)
// TestEveryErrorResponseCarriesID 钉住 JSON-RPC 的硬要求:**每个**响应都要带 id。
//
// 为什么单独写一格:解析错误那格只覆盖了「解析失败」一条路径。曾用
// `ID json.RawMessage` + `omitempty`,nil 时整个 id 字段会从 JSON 里消失 ——
// 客户端拿不到配对依据,会一直等这条的响应。这格遍历所有错误出口,
// 任何一条漏掉 id 都会红。
func TestEveryErrorResponseCarriesID(t *testing.T) {
s := newTestServer()
cases := []struct {
name string
body string
want string // 期望的 id(JSON 字面量)
}{
{"解析失败", `{oops`, "null"},
{"jsonrpc 版本不对", `{"jsonrpc":"1.0","id":7,"method":"ping"}`, "7"},
{"方法不存在", `{"jsonrpc":"2.0","id":7,"method":"no/such"}`, "7"},
{"工具名未知", `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nope"}}`, "7"},
{"工具名缺失", `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{}}`, "7"},
}
for _, c := range cases {
rec := httptest.NewRecorder()
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(c.body)))
out := rec.Body.String()
// 直接查 JSON 里有没有 id 键(而不是靠字符串匹配,那样会漏掉
// `"id":null` 这种情形)
var decoded map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Errorf("%s:响应不是合法 JSON:%v(%q)", c.name, err, out)
continue
}
rawID, ok := decoded["id"]
if !ok {
t.Errorf("★ %s:响应缺少 id 字段 ⇒ 客户端无法配对,会一直等(收到 %q)", c.name, out)
continue
}
if string(rawID) != c.want {
t.Errorf("%s:id 应为 %s,收到 %s", c.name, c.want, string(rawID))
}
}
}
// TestPathParamsReachHandlers 钉住「包装 handler 时路径参数必须到位」。
//
// ★ 这格是端到端撞出来的:主人读自己的信与越权者读别人的信**都**返回
// 「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把正确的收窄改松;
// 做对照才看出是 `chi.URLParam` 拿不到值(httptest 请求没过 chi 的路由)。
//
// 判据用「主人能读自己的信」当锚:它同时证明参数到位**且**收窄没有误伤。
func TestPathParamsReachHandlers(t *testing.T) {
setupDB(t)
mailID, _ := seedAgentAndMail(t, "owner", "/tmp/ws", "主人自己的信")
sessionID := sessionOf(t, mailID)
s := newTestServer()
// agentScope 要求 session_id 与信所属会话一致(这是**正确**的收窄,
// 不是路径参数问题)—— 所以这里带上它。
args := map[string]any{"mail_id": mailID, "session_id": sessionID}
text, isErr := toolText(t, callMsg(t, s, "owner", "read_mail", args))
if isErr {
t.Fatalf("★ 主人读自己的信应该成功,却失败了(路径参数没到位?):%s", text)
}
if !strings.Contains(text, "主人自己的信") {
t.Errorf("应读到自己的信,收到 %q", text)
}
// read_thread 同样带路径参数,一并钉住
text, isErr = toolText(t, callMsg(t, s, "owner", "read_thread", args))
if isErr {
t.Errorf("★ read_thread 也应成功(同一类路径参数):%s", text)
}
}
// callMsg 是 call 的单值版本(拿消息)。
func callMsg(t *testing.T, s *Server, agent, tool string, args map[string]any) *rpcMessage {
t.Helper()
_, msg := call(t, s, agent, tool, args)
return msg
}

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)
}

View File

@ -0,0 +1,372 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"github.com/agentmail/gateway/internal/handler"
)
// httptestRecorder 造一个响应收集器。
func httptestRecorder() *httptest.ResponseRecorder { return httptest.NewRecorder() }
// jsonUnmarshal 是 json.Unmarshal 的薄封装(工具文件里用起来更短)。
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// newMultipartRequest 造一个 multipart/form-data 请求。
func newMultipartRequest(ctx context.Context, body []byte, contentType string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/v1/attachments", bytes.NewReader(body))
r.Header.Set("Content-Type", contentType)
return r.WithContext(ctx)
}
// buildUploadForm 组装上传用的 multipart 表单。
//
// 只**读**文件(打开 + 拷贝),不写。它读的是调用方给出的路径 ——
// 见 tool_impls.go 里 upload_attachment 的注释:这是一处「工具参数即
// 文件系统访问」,只读,且只对本服务自己跑在本机这件事成立(远程
// Streamable HTTP 部署时读的是**网关机器**的路径,不是客户端的)。
func buildUploadForm(path, filename string) ([]byte, string, error) {
if strings.TrimSpace(path) == "" {
return nil, "", fmt.Errorf("缺少 file_path")
}
f, err := os.Open(path)
if err != nil {
return nil, "", fmt.Errorf("打不开 %s:%v", path, err)
}
defer f.Close()
if filename == "" {
filename = filepath.Base(path)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
// 服务端字段名是 "file"(FormFile("file"))—— 写错名字会得到
// "表单里没有 file 字段" 这种看不懂的错。
part, err := mw.CreateFormFile("file", filename)
if err != nil {
return nil, "", err
}
if _, err := io.Copy(part, f); err != nil {
return nil, "", err
}
if err := mw.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), mw.FormDataContentType(), nil
}
// summarize 把 handler 的成功响应转成模型读的文本。
//
// 为什么不直接把 JSON 甩给模型:
// - read_inbox 一次可能返回几十封,每封都带邮件正文缩略 —— 纯 JSON 里
// 转义与字段名会占掉大量 token,而且模型容易看错行(哪个是 subject、
// 哪个是 from)。
// - 人类与模型都需要**能扫读**的形状。
//
// 所以这里按已知的响应形状做小渲染;认不出来的形状回退到原始 JSON
// (宁可费 token,也不要把信息丢了 —— 那会让模型以为邮件是空的)。
func summarize(name string, payload map[string]any) (string, error) {
switch name {
case "read_inbox":
return summarizeInbox(payload), nil
case "read_mail":
return summarizeMail(payload), nil
case "read_thread":
return summarizeThread(payload), nil
case "send_mail":
return summarizeSend(payload), nil
case "forward_mail":
return summarizeSend(payload), nil
case "upload_attachment":
return summarizeUpload(payload), nil
case "suggest_address":
return summarizeSuggest(payload), nil
case "list_contacts":
return summarizeContacts(payload), nil
case "session_participants":
return summarizeParticipants(payload), nil
}
return prettyJSON(payload), nil
}
func prettyJSON(payload map[string]any) string {
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Sprintf("%v", payload)
}
return string(b)
}
func str(m map[string]any, k string) string {
if v, ok := m[k].(string); ok {
return v
}
return ""
}
// summarizeInbox 渲染收件箱:每封一行,带 id(下一步要用)与是否有附件。
func summarizeInbox(payload map[string]any) string {
mails, _ := payload["mails"].([]any)
total := numberOf(payload["total"])
if len(mails) == 0 {
if total > 0 {
return fmt.Sprintf("(本会话/工作区内没有符合条件的未读,但 total=%d)", total)
}
return "收件箱为空。"
}
var b strings.Builder
fmt.Fprintf(&b, "%d 封未读(total=%d):\n\n", len(mails), total)
for _, it := range mails {
m, ok := it.(map[string]any)
if !ok {
continue
}
from := firstNonEmpty(str(m, "from_name"), str(m, "from"), "(未知)")
subj := firstNonEmpty(str(m, "subject"), "(无主题)")
id := str(m, "mail_id")
flag := ""
if n, _ := m["attachments"].([]any); len(n) > 0 {
flag = fmt.Sprintf(" [附件 %d]", len(n))
}
if ws := str(m, "session_workspace"); ws != "" {
flag += " " + ws
}
fmt.Fprintf(&b, "· %s — %s (id: %s)%s\n", from, subj, id, flag)
}
b.WriteString("\n用 read_mail(mail_id=…) 读正文。")
return b.String()
}
// summarizeMail 渲染单封:头部 + 正文 + 附件清单。
func summarizeMail(payload map[string]any) string {
m := payload
if inner, ok := payload["mail"].(map[string]any); ok {
m = inner
}
var b strings.Builder
fmt.Fprintf(&b, "发件人:%s\n主题:%s\n邮件 ID:%s\n日期:%s\n",
firstNonEmpty(str(m, "from_name"), "(未知)"),
firstNonEmpty(str(m, "subject"), "(无主题)"),
str(m, "mail_id"),
firstNonEmpty(str(m, "created_at"), str(m, "sent_at")))
if parent := str(m, "parent_mail_id"); parent != "" {
fmt.Fprintf(&b, "(这是对 %s 的回复)\n", parent)
}
b.WriteString("\n---\n")
b.WriteString(str(m, "body"))
if atts, ok := m["attachments"].([]any); ok && len(atts) > 0 {
b.WriteString("\n\n附件:")
for _, a := range atts {
am, _ := a.(map[string]any)
fmt.Fprintf(&b, "\n· %s (attachment_id: %s, %s)",
firstNonEmpty(str(am, "filename"), "(未命名)"),
str(am, "attachment_id"),
humanSize(numberOf(am["size_bytes"])))
}
}
return b.String()
}
// summarizeThread 渲染线索:按时间顺序每封一段。
func summarizeThread(payload map[string]any) string {
mails, _ := payload["mails"].([]any)
if mails == nil {
mails, _ = payload["thread"].([]any)
}
if len(mails) == 0 {
return "线索里没有邮件。"
}
var b strings.Builder
fmt.Fprintf(&b, "线索共 %d 封:\n\n", len(mails))
for _, it := range mails {
m, _ := it.(map[string]any)
fmt.Fprintf(&b, "── %s · %s(id: %s)\n%s\n\n",
firstNonEmpty(str(m, "from_name"), "(未知)"),
firstNonEmpty(str(m, "subject"), "(无主题)"),
str(m, "mail_id"),
strings.TrimSpace(str(m, "body")))
}
return b.String()
}
func summarizeSend(payload map[string]any) string {
id := str(payload, "mail_id")
sid := str(payload, "session_id")
var b strings.Builder
b.WriteString("已发送")
if id != "" {
fmt.Fprintf(&b, "(mail_id: %s)", id)
}
if sid != "" {
fmt.Fprintf(&b, ",落在会话 %s", sid)
}
if relay := str(payload, "duplicate_relay"); relay != "" {
fmt.Fprintf(&b, "。注意:%s", relay)
}
return b.String()
}
func summarizeUpload(payload map[string]any) string {
// UploadAttachment 返回的是 {attachment:{...}} 这种**嵌套**形状
// —— 按顶层解会得到空 id(homeagent 与 zcode 都踩过)。
att, _ := payload["attachment"].(map[string]any)
if att == nil {
att = payload
}
return fmt.Sprintf("已上传 %s,attachment_id: %s(%s)。在 send_mail 的 attachment_ids 里带上它才会随邮件发出。",
firstNonEmpty(str(att, "filename"), "(未命名)"),
str(att, "attachment_id"),
humanSize(numberOf(att["size_bytes"])))
}
func summarizeSuggest(payload map[string]any) string {
kind := str(payload, "kind")
suggestions, _ := payload["suggestions"].([]any)
if len(suggestions) == 0 {
return fmt.Sprintf("(没有%s建议)", kind)
}
items := make([]string, 0, len(suggestions))
for _, s := range suggestions {
if str, ok := s.(string); ok {
items = append(items, str)
}
}
if len(items) == 0 {
return fmt.Sprintf("(没有%s建议)", kind)
}
return fmt.Sprintf("可选的%s:\n%s", kind, strings.Join(items, "\n"))
}
func summarizeContacts(payload map[string]any) string {
contacts, _ := payload["contacts"].([]any)
if len(contacts) == 0 {
return "没有参与过任何会话。用 suggest_address 查可投递地址。"
}
var b strings.Builder
fmt.Fprintf(&b, "共 %d 条会话:\n\n", len(contacts))
for _, it := range contacts {
c, _ := it.(map[string]any)
unread := numberOf(c["unread_count"])
flag := ""
if unread > 0 {
flag = fmt.Sprintf(" ★ %d 未读", unread)
}
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n session_id: %s\n",
firstNonEmpty(str(c, "peer"), "(未知)"),
firstNonEmpty(str(c, "subject"), "(无主题)"),
flag,
firstNonEmpty(str(c, "address"), "(无可寻址地址)"),
str(c, "session_id"))
}
return b.String()
}
func summarizeParticipants(payload map[string]any) string {
parts, _ := payload["participants"].([]any)
if len(parts) == 0 {
return "会话里没有其他参与方。"
}
var b strings.Builder
b.WriteString("参与方:\n")
for _, it := range parts {
p, _ := it.(map[string]any)
flag := ""
if !boolOf(p["replied"]) {
flag = "(还没回应)"
}
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n",
firstNonEmpty(str(p, "role"), "?"),
firstNonEmpty(str(p, "name"), "(未知)"),
flag,
firstNonEmpty(str(p, "address"), "(无)"))
}
return b.String()
}
// ---- 小工具 ----
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func numberOf(v any) int {
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case json.Number:
n, _ := t.Int64()
return int(n)
}
return 0
}
func boolOf(v any) bool {
b, _ := v.(bool)
return b
}
func humanSize(n int) string {
switch {
case n <= 0:
return "0 B"
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.1f KB", float64(n)/1024)
default:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
}
// agentNameOfRequest 供 handler 包在需要时取身份。
//
// 单独一个 invoke:DownloadAttachment 的响应体是**附件字节**而不是 JSON,
// 走通用的 invoke 会把二进制当 JSON 解析(静默得到 nil),
// 于是成功时也会报"解析不出 error"。所以这里自己收集。
func invokeDownload(ctx context.Context, target, id, savePath string) (int, map[string]any, string) {
// 路径参数 id 必须注入(DownloadAttachment 用 pathUUID → chi.URLParam)。
// 注入后**不要再** WithContext(ctx) —— 那会覆盖掉 RouteContext。
r := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
rec := httptest.NewRecorder()
handler.DownloadAttachment(rec, r)
body := rec.Body.Bytes()
if rec.Code < 200 || rec.Code >= 300 {
var out map[string]any
_ = json.Unmarshal(body, &out)
return rec.Code, out, string(body)
}
// 落盘(建父目录)。写失败要**报错**而不是假装成功 ——
// 模型会以为文件在那儿,然后 upload_attachment 打不开它。
if dir := filepath.Dir(savePath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return http.StatusInternalServerError,
map[string]any{"error": "建目录失败:" + err.Error()}, ""
}
}
if err := os.WriteFile(savePath, body, 0o644); err != nil {
return http.StatusInternalServerError,
map[string]any{"error": "写文件失败:" + err.Error()}, ""
}
return http.StatusOK, map[string]any{
"saved_path": savePath,
"size_bytes": len(body),
"description": fmt.Sprintf("已保存 %s(%s)", savePath, humanSize(len(body))),
}, ""
}

View File

@ -0,0 +1,364 @@
package mcp
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/handler"
)
// sessionIDProp 是 read_mail / read_thread / forward_mail 共用的参数说明。
//
// ★ 为什么这三个工具必须暴露它:handler 侧的 agentScope 靠 session_id 把
// 读操作限在你当前所在那条会话(会话之间的记忆是隔离的)。不暴露它 ⇒
// 模型怎么调都会被拒「这封信不在你当前所在的那条会话里」,而那条文案看起来
// 像权限过紧,其实是**缺参数**。这个洞是端到端跑出来的(带 session_id 的
// 对照组成功、不带失败)。
var sessionIDProp = map[string]any{
"type": "string",
"description": "★ 你当前所在的会话 id。多会话并行时必传," +
"否则读操作会被拒(会话之间的记忆是隔离的)",
}
// ---- read_inbox ----
func (t *Tools) ReadInbox() Tool {
return fnTool{
schema: ToolSchema{
Name: "read_inbox",
Description: "列出你的收件箱(按工作区与会话收窄)。缺 workspace 会报 400 —— 请带上你所处工作区的绝对路径。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"workspace": strProp("★ 必需。你所处工作区的绝对路径,例如 /home/program/agentmail"),
"status": strProp("unread(默认)| all | read"),
"limit": numProp("最多返回多少封,默认 10"),
"session_id": strProp("可选。只列这条会话的邮件 —— 多会话并行时必传,否则会把别人的未读一并标成已读"),
}, "workspace"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
target := withQuery("/api/v1/mail/inbox", map[string]string{
"workspace": strings.TrimSpace(argStr(args, "workspace")),
"status": argStr(args, "status"),
"limit": argStr(args, "limit"),
"session_id": argStr(args, "session_id"),
})
code, payload, raw := invoke(ctx, handler.GetInbox, newRequest(ctx, http.MethodGet, target, nil))
return render("read_inbox", code, payload, raw)
},
}
}
// ---- read_mail ----
func (t *Tools) ReadMail() Tool {
return fnTool{
schema: ToolSchema{
Name: "read_mail",
Description: "按邮件 id 读完整正文(含附件清单)。注意:投递时该信已标为已读,read_inbox 默认只看未读,读不到它。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"mail_id": strProp("邮件 ID"),
"session_id": sessionIDProp,
}, "mail_id"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "mail_id"))
target := withQuery("/api/v1/agent/mail/"+id, map[string]string{"session_id": strings.TrimSpace(argStr(args, "session_id"))})
req := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
code, payload, raw := invoke(ctx, handler.AgentGetMail, req)
return render("read_mail", code, payload, raw)
},
}
}
// ---- read_thread ----
func (t *Tools) ReadThread() Tool {
return fnTool{
schema: ToolSchema{
Name: "read_thread",
Description: "读一封邮件所在的整条线索(按时间顺序,含各封的正文与投递方)。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"mail_id": strProp("线索中任意一封邮件的 ID"),
"session_id": sessionIDProp,
}, "mail_id"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "mail_id"))
target := withQuery("/api/v1/agent/mail/"+id+"/thread", map[string]string{"session_id": strings.TrimSpace(argStr(args, "session_id"))})
req := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
code, payload, raw := invoke(ctx, handler.AgentGetMailThread, req)
return render("read_thread", code, payload, raw)
},
}
}
// ---- send_mail ----
func (t *Tools) SendMail() Tool {
return fnTool{
schema: ToolSchema{
Name: "send_mail",
Description: "发送邮件。三维地址 name@path.session:省略 session=投到默认会话,.new 强制新建," +
".具体别名 必须已存在。回复来信请传 reply_to。",
Annotations: writeSafe,
InputSchema: objSchema(map[string]any{
"to": strProp("收件人三维地址,如 homeagent@/home/program/agentmail"),
"subject": strProp("主题"),
"body": strProp("正文(Markdown)"),
"cc": strProp("抄送,逗号分隔多个三维地址"),
"reply_to": strProp("回复某封邮件时传其 mail_id"),
"session_alias": strProp("可选:指定会话别名"),
"attachment_ids": arrayProp("附件 ID 列表(先用 upload_attachment 取得)"),
}, "to"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
body := map[string]any{
"to": argStr(args, "to"),
"subject": argStr(args, "subject"),
"body": argStr(args, "body"),
}
if v := argStr(args, "cc"); v != "" {
body["cc"] = v
}
if v := argStr(args, "reply_to"); v != "" {
body["reply_to"] = v
}
if v := argStr(args, "session_alias"); v != "" {
body["session_alias"] = v
}
if ids := argStrList(args, "attachment_ids"); len(ids) > 0 {
body["attachment_ids"] = ids
}
code, payload, raw := invoke(ctx, handler.SendMail, newRequest(ctx, http.MethodPost, "/api/v1/mail/send", body))
return render("send_mail", code, payload, raw)
},
}
}
// ---- forward_mail ----
func (t *Tools) ForwardMail() Tool {
return fnTool{
schema: ToolSchema{
Name: "forward_mail",
Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。",
Annotations: writeSafe,
InputSchema: objSchema(map[string]any{
"mail_id": strProp("要转发的邮件 ID"),
"to": strProp("新收件人的三维地址"),
"comment": strProp("转发说明,置于引用原文之前"),
"subject": strProp("可选:自定义主题;留空则自动加 Fwd: 前缀"),
"cc": strProp("抄送,逗号分隔多个三维地址"),
}, "mail_id", "to"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "mail_id"))
body := map[string]any{
"to": argStr(args, "to"),
"comment": argStr(args, "comment"),
}
if v := argStr(args, "subject"); v != "" {
body["subject"] = v
}
if v := argStr(args, "cc"); v != "" {
body["cc"] = v
}
target := "/api/v1/mail/" + id + "/forward"
req := withRouteParams(newRequest(ctx, http.MethodPost, target, body), map[string]string{"id": id})
code, payload, raw := invoke(ctx, handler.ForwardMail, req)
return render("forward_mail", code, payload, raw)
},
}
}
// ---- upload_attachment ----
func (t *Tools) UploadAttachment() Tool {
return fnTool{
schema: ToolSchema{
Name: "upload_attachment",
Description: "上传本地文件作为邮件附件,返回 attachment_id。拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。",
Annotations: writeSafe,
InputSchema: objSchema(map[string]any{
"file_path": strProp("要上传的本地文件绝对路径"),
"filename": strProp("自定义展示文件名,默认取路径的最后一段"),
}, "file_path"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
// UploadAttachment 是 multipart handler,包装它要造 multipart 表单。
// 不读文件内容(工具参数里没有内容字段,只有路径)—— 这里
// **由服务端**去读那个路径。
//
// ⚠ 这条本身值得一个判据:服务端按参数里的路径读本机文件,
// 是一处「工具参数即文件系统访问」。只读、不写,且路径由**已认证**
// 的 Agent 给出 —— 与 /attachments 端点本身的口径一致
// (该端点也是收文件、只是从请求体取)。真正要收紧的是
// write_file 一类写工具,本服务**不提供**(见 tools 数量判据)。
path := strings.TrimSpace(argStr(args, "file_path"))
name := strings.TrimSpace(argStr(args, "filename"))
code, payload, raw := invokeUpload(ctx, path, name)
return render("upload_attachment", code, payload, raw)
},
}
}
// ---- download_attachment ----
func (t *Tools) DownloadAttachment() Tool {
return fnTool{
schema: ToolSchema{
Name: "download_attachment",
Description: "下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。",
Annotations: writeSafe,
InputSchema: objSchema(map[string]any{
"attachment_id": strProp("附件 ID"),
"save_path": strProp("保存到的本地绝对路径"),
}, "attachment_id", "save_path"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "attachment_id"))
save := strings.TrimSpace(argStr(args, "save_path"))
if save == "" {
return "", fmt.Errorf("缺少 save_path")
}
target := "/api/v1/attachments/" + id
code, payload, raw := invokeDownload(ctx, target, id, save)
return render("download_attachment", code, payload, raw)
},
}
}
// ---- suggest_address ----
func (t *Tools) SuggestAddress() Tool {
return fnTool{
schema: ToolSchema{
Name: "suggest_address",
Description: "查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的工作目录;" +
"name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"name": strProp("收件人名;留空则列出所有候选收件人"),
"path": strProp("工作目录;与 name 同时给出才列会话"),
}),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
target := withQuery("/api/v1/agent/contacts/suggest", map[string]string{
"name": strings.TrimSpace(argStr(args, "name")),
"path": strings.TrimSpace(argStr(args, "path")),
})
code, payload, raw := invoke(ctx, handler.AgentSuggestAddress, newRequest(ctx, http.MethodGet, target, nil))
return render("suggest_address", code, payload, raw)
},
}
}
// ---- list_contacts ----
func (t *Tools) ListContacts() Tool {
return fnTool{
schema: ToolSchema{
Name: "list_contacts",
Description: "列出自己参与过的会话及各自的可投递地址、未读数、剩余往返预算。用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"limit": numProp("最多列出多少条,默认 20"),
}),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
target := withQuery("/api/v1/agent/contacts", nil)
code, payload, raw := invoke(ctx, handler.AgentListContacts, newRequest(ctx, http.MethodGet, target, nil))
return render("list_contacts", code, payload, raw)
},
}
}
// ---- session_participants ----
func (t *Tools) SessionParticipants() Tool {
return fnTool{
schema: ToolSchema{
Name: "session_participants",
Description: "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"session_id": strProp("会话 ID"),
}, "session_id"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "session_id"))
target := "/api/v1/agent/sessions/" + id + "/participants"
req := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
code, payload, raw := invoke(ctx, handler.AgentSessionParticipants, req)
return render("session_participants", code, payload, raw)
},
}
}
// fnTool:一个由函数实现的 Tool。
type fnTool struct {
schema ToolSchema
run func(ctx context.Context, args map[string]any) (string, error)
}
func (f fnTool) Schema() ToolSchema { return f.schema }
func (f fnTool) Run(ctx context.Context, args map[string]any) (string, error) {
// ctx 是**当前请求的** context,AgentAuth 放进去的身份就在里面。
// 绝不能在这里换成 context.Background() —— 那会让每个工具调用
// 都变成 Unauthorized(症状是「模型说连上了但什么都读不到」)。
return f.run(ctx, args)
}
// invokeUpload 造 multipart 请求调 UploadAttachment。
func invokeUpload(ctx context.Context, path, name string) (int, map[string]any, string) {
body, contentType, err := buildUploadForm(path, name)
if err != nil {
return http.StatusBadRequest, map[string]any{"error": err.Error()}, ""
}
r := newMultipartRequest(ctx, body, contentType)
rec := httptestRecorder()
handler.UploadAttachment(rec, r)
var out map[string]any
_ = jsonUnmarshal(rec.Body.Bytes(), &out)
return rec.Code, out, rec.Body.String()
}
// ---- connect_to_server ----
// ConnectToServer 回报连通性。
//
// 与独立进程那版的差别值得写清楚:那一版要真正去 `POST /agent/register`
// (因为它是个**局外**进程,得让网关知道"这个客户端活着")。而 MCP 端点
// 挂在 AgentAuth 之内 —— **能调到这个工具本身就证明凭证已通过**,
// Agent 的 last_seen 也已被 AgentAuth 刷新。所以这里只做一次真实读
// (list_contacts 的数据源)来确认数据库那一侧也通,而不是空口说 ok。
//
// 曾经踩过的坑(保留这条注释):那一版的 register 请求只发
// `X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
// secret ⇒ secret-only 的 Agent 必然 400,而模型只看到一个 4xx 拼不出
// 该改什么。包装 handler 后这类分叉从根上消失。
func (t *Tools) ConnectToServer() Tool {
return fnTool{
schema: ToolSchema{
Name: "connect_to_server",
Annotations: readOnly,
Description: "连接到 AgentMail Gateway:用当前配置的身份确认连通性并报告会话概况。" +
"能调用本工具即表示凭证已通过认证。首次接入或换环境时调用一次确认。",
InputSchema: objSchema(map[string]any{}),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
target := "/api/v1/agent/contacts"
code, payload, raw := invoke(ctx, handler.AgentListContacts, newRequest(ctx, http.MethodGet, target, nil))
if code < 200 || code >= 300 {
return render("connect_to_server", code, payload, raw)
}
contacts, _ := payload["contacts"].([]any)
return fmt.Sprintf("已连接。凭证有效,参与过 %d 条会话。", len(contacts)), nil
},
}
}

View File

@ -0,0 +1,266 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
)
// Tools 构造网关的 11 个 MCP 工具。
//
// # 为什么是**包装 handler**而不是直调 repo
//
// 早先那版 MCP 在独立进程里用 HTTP 调本网关,于是工具语义是**手抄**的一份:
// 参数、收窄、配额、错误文案各写一遍。实测已抓到后果 ——
// `connect_to_server` 只发 `X-Agent-Secret` 头,而 `/agent/register` 只认
// Bearer 或 body 里的 `secret`,于是 secret-only 的 Agent 调它必然 400,
// 而模型看到 401/400 拼不出该改什么。
//
// 包装 handler 之后:**只有一份语义**。工作区收窄(`GetInbox` 缺 workspace
// 直接 400)、会话收窄(`agentScope`)、Agent↔Agent 冷静期、配额、
// 附件保护目录 —— 全部是同一条代码路径,不是复述。
//
// # 身份从哪来
//
// 不从工具参数里取身份。`AgentAuth` 已把身份放进 request context
// (`middleware.AgentNameKey`),每个内部请求都带上它。所以工具**无法**
// 通过参数冒充别的 Agent —— 那正是 2026-10-02 修掉的越权形状
// (`AgentMayReadSession` 的 `if scope == nil { return true }`)。
type Tools struct{}
// NewTools 造工具集。
func NewTools() *Tools { return &Tools{} }
// RegisterAll 把 11 个工具注册进端点。
//
// 工具名与参数名与 pi / dsh / opencode / zcode 四桥**逐字一致** —— 同一件事
// 在任何平台上必须是同一种做法,否则会出现「只在这个平台上模型不会回信」
// 这类单平台复现、排查代价最高的问题。
func (t *Tools) RegisterAll(s *Server) {
s.RegisterAll(
t.ReadInbox(), t.ReadMail(), t.ReadThread(), t.SendMail(),
t.ForwardMail(), t.UploadAttachment(), t.DownloadAttachment(),
t.SuggestAddress(), t.ListContacts(), t.SessionParticipants(),
t.ConnectToServer(),
)
}
// invoke 调一个 handler,拿到状态码与解码后的 JSON。
//
// 这是全部工具共用的骨架:造请求 → 交给 handler → 解读结果。
// handler 用 httptest.ResponseRecorder 收集(它是标准库类型,零依赖),
// 因为我们要的是"handler 的语义",不是真的走网络。
func invoke(ctx context.Context, h http.HandlerFunc, req *http.Request) (int, map[string]any, string) {
// ★ 不要在这里 `req = req.WithContext(ctx)`。
//
// req 身上已经挂了两样东西,而用 ctx 重建会**丢掉后面挂的那一样**:
// - 身份(middleware.AgentNameKey):由 newRequest 挂上
// - 路径参数(chi.RouteCtxKey):由 withRouteParams 挂上
//
// withRouteParams 之后调 WithContext(ctx),chi 的 RouteContext 就没了 ⇒
// handler 里 chi.URLParam 恒为空 ⇒ 报「Invalid id」。
// ctx 是**外层请求**的 context,而 req 已经是它的派生物(newRequest 里
// 做过一次 WithContext);所以这里什么都不做才是对的。
// (判据:TestPathParamsReachHandlers)
_ = ctx
rec := httptest.NewRecorder()
h(rec, req)
var out map[string]any
raw := rec.Body.Bytes()
_ = json.Unmarshal(raw, &out)
return rec.Code, out, string(raw)
}
// render 把 handler 的响应转成模型看的文本。
//
// 规则:成功时给可读摘要(而**不是**整坨 JSON —— 模型读 50 封邮件的 JSON
// 既费 token 又容易看错行);失败时**原样带上 handler 的错误文案**,
// 那是让人/模型能改道的信息,不能吞掉。
func render(name string, status int, payload map[string]any, raw string) (string, error) {
if status >= 200 && status < 300 {
return summarize(name, payload)
}
msg := errorMessage(payload)
if msg == "" {
msg = strings.TrimSpace(raw)
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", status)
}
return "", fmt.Errorf("%s", msg)
}
func errorMessage(payload map[string]any) string {
if payload == nil {
return ""
}
for _, k := range []string{"error", "message", "suggestion"} {
if v, ok := payload[k].(string); ok && v != "" {
return v
}
}
return ""
}
// ---- 参数取值 ----
//
// 工具参数一律宽容:数字既能是 JSON number 也能是字符串(模型两种都发过)。
// 严格解码会让一个本来能用的调用失败,而失败信息("类型不匹配")对模型没用。
func argStr(args map[string]any, key string) string {
if v, ok := args[key]; ok {
switch t := v.(type) {
case string:
return t
case float64:
// 纯整数的 number 当字符串用(id 常见地被模型写成数字)
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%v", t)
case bool:
return fmt.Sprintf("%t", t)
case nil:
return ""
}
}
return ""
}
func argInt(args map[string]any, key string, def int) int {
s := argStr(args, key)
if s == "" {
return def
}
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err == nil {
return n
}
return def
}
// argBool 取布尔。字符串 "true"/"false" 也认。
func argBool(args map[string]any, key string) bool {
switch v := args[key].(type) {
case bool:
return v
case string:
return strings.EqualFold(strings.TrimSpace(v), "true")
}
return false
}
// argStrList 取字符串数组(逗号分隔的字符串也认)。
func argStrList(args map[string]any, key string) []string {
switch v := args[key].(type) {
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s := argStr(map[string]any{"v": item}, "v"); s != "" {
out = append(out, s)
}
}
return out
case string:
parts := strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\n'
})
return parts
}
return nil
}
// withRouteParams 把路径参数注入 request context。
//
// ★ 为什么必须这么做:被包装的 handler 用 `chi.URLParam(r, "id")` 取路径参数
// (`pathUUID` → `uuid.Parse(chi.URLParam(r, "id"))`)。而我们用
// httptest.NewRequest 造的请求**没有经过 chi 的路由**,URLParam 恒为空串
// ⇒ 任何带路径参数的工具(read_mail / read_thread / forward_mail /
// download_attachment / session_participants)都会报「Invalid id」。
//
// 这个 bug 是**端到端**撞出来的:主人读自己的信与越权者读别人的信**都**返回
// 「Invalid id」。对照实验立刻说明这不是权限问题而是参数没到位 —— 如果只看
// 越权那一次,会误判成「收得太紧」,进而把正确的收窄改松。
//
// 用 chi 自己的 RouteContext 注入,而不是自造 context key:handler 读的是
// chi.URLParam,两边必须说同一种话。
func withRouteParams(r *http.Request, params map[string]string) *http.Request {
if len(params) == 0 {
return r
}
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// objSchema 造一个 object 类型的入参 schema。
func objSchema(props map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
s["required"] = required
}
return s
}
func strProp(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
}
func numProp(desc string) map[string]any {
return map[string]any{"type": "integer", "description": desc}
}
func boolProp(desc string) map[string]any {
return map[string]any{"type": "boolean", "description": desc}
}
func arrayProp(desc string) map[string]any {
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
}
// 只读标注。宿主据此算风险等级并在 plan 档放行(漏传会被全拒)。
var readOnly = map[string]any{"readOnlyHint": true, "destructiveHint": false}
// 写但不破坏性(发信、转发:会改变别人的收件箱,但不删数据)。
var writeSafe = map[string]any{"readOnlyHint": false, "destructiveHint": false}
// hasWorkspaceArg 在没有 workspace 时给出**带例子**的错误。
//
// 直接复用 GetInbox 的错误文案即可(包装 handler 的好处):
// 那是唯一的口径,模型见一次就记住。
func newRequest(ctx context.Context, method, target string, body any) *http.Request {
var r *http.Request
if body != nil {
buf, _ := json.Marshal(body)
r = httptest.NewRequest(method, target, bytes.NewReader(buf))
r.Header.Set("Content-Type", "application/json")
} else {
r = httptest.NewRequest(method, target, nil)
}
// 身份从调用链的 context 继承(AgentAuth 放进去的)。
r = r.WithContext(ctx)
return r
}
// withQuery 在 URL 上加查询参数。
func withQuery(target string, kv map[string]string) string {
u, err := url.Parse(target)
if err != nil {
return target
}
q := u.Query()
for k, v := range kv {
if v != "" {
q.Set(k, v)
}
}
u.RawQuery = q.Encode()
return u.String()
}