diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index dbf272e..1a3d233 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "github.com/agentmail/gateway/internal/config" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/handler" + "github.com/agentmail/gateway/internal/mcp" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/push" "github.com/agentmail/gateway/internal/repo" @@ -73,6 +74,13 @@ func main() { scheduler.Start() 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.Use(chimw.Logger) @@ -116,6 +124,16 @@ func main() { // ---- Agent 侧(X-Agent-Name + X-Agent-Secret) ---- r.Group(func(r chi.Router) { 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("/mail/send", handler.SendMail) r.Get("/mail/inbox", handler.GetInbox) diff --git a/server/internal/mcp/mcp_test.go b/server/internal/mcp/mcp_test.go new file mode 100644 index 0000000..7f636ae --- /dev/null +++ b/server/internal/mcp/mcp_test.go @@ -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 +} diff --git a/server/internal/mcp/server.go b/server/internal/mcp/server.go new file mode 100644 index 0000000..80f9737 --- /dev/null +++ b/server/internal/mcp/server.go @@ -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) +} diff --git a/server/internal/mcp/support.go b/server/internal/mcp/support.go new file mode 100644 index 0000000..289b00a --- /dev/null +++ b/server/internal/mcp/support.go @@ -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))), + }, "" +} diff --git a/server/internal/mcp/tool_impls.go b/server/internal/mcp/tool_impls.go new file mode 100644 index 0000000..4ed6812 --- /dev/null +++ b/server/internal/mcp/tool_impls.go @@ -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 + }, + } +} diff --git a/server/internal/mcp/tools.go b/server/internal/mcp/tools.go new file mode 100644 index 0000000..f01e4e6 --- /dev/null +++ b/server/internal/mcp/tools.go @@ -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() +}