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