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