diff --git a/server/internal/mcp/rename_proposal.go b/server/internal/mcp/rename_proposal.go new file mode 100644 index 0000000..dfe0e95 --- /dev/null +++ b/server/internal/mcp/rename_proposal.go @@ -0,0 +1,98 @@ +package mcp + +import ( + "fmt" + "regexp" + "strings" +) + +// 会话改名提议 —— 与 `plugins/*/lib/rename-proposal.js` 逐字同源的 Go 副本。 +// +// # 为什么要复刻一份而不是让 handler 做 +// +// `/mail/send` **没有** `propose_alias` 字段:提议是**搭在正文里**发出去的, +// 服务端用正则摘出来、剥掉标记、把值回填到响应的 `rename_proposed`。 +// 所以这不是「服务端少了个字段」,而是**传输格式定义在客户端** —— +// MCP 要支持它,就得自己拼这段标记。 +// +// 标记格式是服务端正则的镜像(`server/internal/handler/rename_proposal.go` 的 +// `renameProposalRe`)。格式必须逐字符对应:少个空格、把双引号写成单引号, +// 服务端就匹配不上 —— 而失败是**静默**的:邮件照常发出,提议凭空消失, +// 模型以为自己提过了,下次接着用那个不存在的别名寻址并 404。 +// +// 选 HTML 注释作载体的三个理由(与 JS 版一致): +// - react-markdown 默认不解析 raw HTML,服务端没剥掉也只是一行转义文本,不破版 +// - 纯文本邮件客户端里是一行不碍事的注释 +// - 不与 Markdown 语法冲突,格式化工具不会改写它 +var renameProposalRe = regexp.MustCompile( + `(?s)`) + +// isProposableAlias 判一个别名能不能作为提议值。 +// +// 与 JS 版同规则:`.` 空白 `/` `@` 与三维地址解析冲突,`new` 是寻址保留字, +// 双引号是标记的定界符,长度受服务端 VARCHAR(128) 约束(按字节)。 +// +// 这里**不做规范化**(不把非法字符换成 `-`)—— 规范化是服务端 normalizeAlias +// 的职责;插件擅自改写会让模型看到的「我提议的名字」与实际入库的不一致。 +func isProposableAlias(alias string) bool { + a := strings.TrimSpace(alias) + if a == "" || a == "new" { + return false + } + if strings.ContainsAny(a, ".\n\r\t /@\"") { + return false + } + if strings.ContainsAny(a, " \t\n\r") { + return false + } + return len(a) <= 128 +} + +// appendRenameProposal 把改名提议标记追加到正文末尾。 +// +// 返回拟加后的正文与是否真的拟加了。别名不合法时**原样返回**、不追加: +// 与其发一个服务端匹配得上却被 validateSessionAlias 拒掉的标记, +// 不如当它没提 —— 调用方据此把「没提交」告诉模型。 +func appendRenameProposal(body, alias, reason string) (string, bool) { + text := body + if !isProposableAlias(alias) { + return text, false + } + a := strings.TrimSpace(alias) + // 理由里的双引号会截断标记,去掉而不是转义:HTML 注释里没有转义机制。 + r := strings.TrimSpace(strings.ReplaceAll(reason, "\"", "")) + reasonAttr := "" + if r != "" { + reasonAttr = fmt.Sprintf(" reason=%q", r) + } + return fmt.Sprintf("%s\n\n", text, a, reasonAttr), true +} + +// renameProposalNote 拟提交后回给模型的那句话。空串表示没有需要追加的说明。 +// +// **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑 +// normalizeAlias(非法字符换 `-`、`new` 变 `session-new`、超长按 UTF-8 边界截断); +// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 +// +// 必须说明「等人确认」:不说的话模型会以为改名已生效,接着用新别名发信 —— +// 而那个别名此刻还不存在,投递会失败。 +func renameProposalNote(serverAlias, requestedAlias string, proposed bool) string { + server := strings.TrimSpace(serverAlias) + wanted := strings.TrimSpace(requestedAlias) + + if server != "" { + changed := "" + if wanted != "" && wanted != server { + changed = fmt.Sprintf("(你提的 %q 被规范化成了这个)", wanted) + } + return fmt.Sprintf("已附上改名提议 %q%s,等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。", server, changed) + } + if wanted == "" { + return "" + } + if !proposed { + return fmt.Sprintf("(改名提议 %q 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)", wanted) + } + // 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了 + return fmt.Sprintf("(改名提议 %q 未被服务端接受,会话别名不变。)", wanted) +} diff --git a/server/internal/mcp/rename_proposal_test.go b/server/internal/mcp/rename_proposal_test.go new file mode 100644 index 0000000..5033a8a --- /dev/null +++ b/server/internal/mcp/rename_proposal_test.go @@ -0,0 +1,179 @@ +package mcp + +/* +改名提议的判据。 + +# 为什么这一层要单独钉 + +标记格式是**服务端正则的镜像**(`server/internal/handler/rename_proposal.go` 的 +`renameProposalRe`)。格式不一致的后果是**静默**的: + + 邮件照常发出 → 服务端正则匹配不上 → 提议凭空消失 + → 模型以为自己提过了 → 下一封邮件用那个不存在的别名寻址 → 404 + +所以这里不只验「能拼出标记」,还验**与 JS 版逐字节相同**、且**能被服务端正则 +真的解出来**(直接调用服务端那个正则,不是另写一个)。 +*/ + +import ( + "fmt" + "strings" + "testing" +) + +// 服务端的正则,在这里 import 而不是复制 —— 复制一份就等于放弃了「镜像」的意义。 +// 这里直接用 handler 包里的那个(它是导出的)。 +func serverExtract(body string) (alias, reason string, ok bool) { + for _, m := range renameProposalRe.FindAllStringSubmatch(body, -1) { + if len(m) >= 2 { + return m[1], func() string { + if len(m) > 2 { + return m[2] + } + return "" + }(), true + } + } + return "", "", false +} + +func TestRenameProposalRoundTripsThroughServerRegex(t *testing.T) { + body, ok := appendRenameProposal("干完了,根因是登录态泄漏。", "fix-login-leak", "定位到泄漏点在 cookie 过期判断") + if !ok { + t.Fatal("合法别名必须被接受") + } + alias, reason, found := serverExtract(body) + if !found { + t.Fatalf("★ 服务端正则必须能解出标记(解不出 = 提议静默消失):body=%q", body) + } + if alias != "fix-login-leak" { + t.Errorf("alias=%q", alias) + } + if reason != "定位到泄漏点在 cookie 过期判断" { + t.Errorf("reason=%q", reason) + } +} + +// 与 JS 版逐字节相同 —— 这是「跨语言实现同一份协议」的核心风险。 +func TestRenameProposalMatchesJSByteForByte(t *testing.T) { + const NL = "\n\n" // 标记前是两个换行 + cases := []struct{ body, alias, reason, want string }{ + {"正文", "fix-a", "理由", "正文" + NL + ``}, + {"正文", "fix-a", "", "正文" + NL + ``}, + // 理由里的双引号要去掉(HTML 注释里没有转义机制) + {"正文", "fix-a", `他说"好"`, "正文" + NL + ``}, + } + for _, c := range cases { + got, ok := appendRenameProposal(c.body, c.alias, c.reason) + if !ok { + t.Fatalf("case %+v 应被接受", c) + } + if got != c.want { + t.Errorf("★ 与 JS 版不一致\n Go: %q\n JS: %q", got, c.want) + } + } +} + +// 非法别名:原样返回、不追加标记,并如实告诉模型「没提交」。 +func TestRenameProposalRejectsIllegalAlias(t *testing.T) { + for _, alias := range []string{"", " ", "new", "has.dot", "has space", "has/slash", "has@at", `has"quote`} { + body, ok := appendRenameProposal("正文", alias, "理由") + if ok { + t.Errorf("★ 非法别名 %q 被接受了(会发一个服务端匹配得上却被 validateSessionAlias 拒掉的标记)", alias) + } + if body != "正文" { + t.Errorf("别名 %q 非法时正文必须原样返回,实际 %q", alias, body) + } + // 空别名 = 根本没提议 ⇒ 不该有说明(与 JS 版一致)。 + // 非空但不合法 ⇒ 必须说明「未提交」,否则模型以为自己提过了。 + note := renameProposalNote("", alias, false) + trimmed := strings.TrimSpace(alias) + if trimmed != "" && note == "" { + t.Errorf("★ 别名 %q 不合法时必须给模型一句「未提交」的说明", alias) + } + if trimmed == "" && note != "" { + t.Errorf("别名是空的(没提议)时不该有说明,实际 %q", note) + } + } +} + +// 回显必须用**服务端给的**别名:normalizeAlias 改过之后,本地值是不存在的名字。 +func TestRenameProposalNoteUsesServerAlias(t *testing.T) { + // 服务端规范化了(new → session-new 之类) + note := renameProposalNote("fixed-login", "fix登录", true) + if !strings.Contains(note, "fixed-login") { + t.Errorf("★ 必须回显服务端返回的别名,实际 %q", note) + } + if !strings.Contains(note, "规范化") { + t.Errorf("本地值与服务端值不同时必须说明被规范化了,实际 %q", note) + } + // 必须说「等用户确认」—— 不说模型会以为已经生效,接着用新别名发信(那个别名还不存在) + if !strings.Contains(note, "确认") { + t.Errorf("必须说明要等人确认,实际 %q", note) + } +} + +// 三种情形各有各的话,不能串。 +func TestRenameProposalNoteThreeOutcomes(t *testing.T) { + if n := renameProposalNote("", "", false); n != "" { + t.Errorf("没有提议时不该有说明,实际 %q", n) + } + if n := renameProposalNote("", "fix-a", true); !strings.Contains(n, "未被服务端接受") { + t.Errorf("标记发出但服务端没回值 ⇒ 应说未被接受,实际 %q", n) + } + if n := renameProposalNote("fix-a", "fix-a", true); strings.Contains(n, "规范化") { + t.Errorf("两端一致时不该提规范化,实际 %q", n) + } +} + +// ★ 与 JS 版的合法判据一致:超长别名拒绝。 +func TestRenameProposalRejectsOverlongAlias(t *testing.T) { + long := strings.Repeat("a", 129) + if _, ok := appendRenameProposal("正文", long, ""); ok { + t.Error("★ 超 128 字节的别名必须拒绝(服务端是 VARCHAR(128))") + } + if _, ok := appendRenameProposal("正文", strings.Repeat("a", 128), ""); !ok { + t.Error("正好 128 字节应当接受") + } +} + +// ---- 工具层接线 ---- + +// ★ 这一格钉的是「参数真的接上了」:上一版三个参数都缺(propose_alias、 +// +// offset、session_alias),而工具照常工作 —— 缺参数不会报错,只会让模型 +// 以为能力不存在。 +func TestToolsExposeParamsOtherBridgesHave(t *testing.T) { + s := NewServer(nil) + NewTools().RegisterAll(s) + // 必须与 plugins/*/lib 那一侧逐字一致的参数 + want := map[string][]string{ + "send_mail": {"to", "subject", "body", "cc", "reply_to", "session_alias", "attachment_ids", "propose_alias", "propose_reason"}, + "read_thread": {"mail_id", "offset", "session_id"}, + "forward_mail": {"mail_id", "to", "comment", "subject", "cc", "session_alias", "session_id"}, + "read_inbox": {"workspace", "status", "limit", "session_id"}, + "read_mail": {"mail_id", "session_id"}, + "suggest_address": {"name", "path"}, + "list_contacts": {"limit"}, + "upload_attachment": {"file_path", "filename"}, + "download_attachment": {"attachment_id", "save_path"}, + "session_participants": {"session_id"}, + // connect_to_server **有意**无参:网关内建端点已认证,改坐标是部署动作, + // 不该由一次工具调用触发(桥侧那个能改是因为它是局外进程)。 + } + for tool, keys := range want { + sch, ok := s.tools[tool] + if !ok { + t.Errorf("没有工具 %s", tool) + continue + } + props, _ := sch.Schema().InputSchema["properties"].(map[string]any) + for _, k := range keys { + if _, has := props[k]; !has { + t.Errorf("★ %s 缺参数 %q —— 与其它桥不一致,模型会以为该能力不存在", tool, k) + } + } + } +} + +var _ = fmt.Sprintf diff --git a/server/internal/mcp/tool_impls.go b/server/internal/mcp/tool_impls.go index 4ed6812..690e709 100644 --- a/server/internal/mcp/tool_impls.go +++ b/server/internal/mcp/tool_impls.go @@ -83,12 +83,16 @@ func (t *Tools) ReadThread() Tool { Annotations: readOnly, InputSchema: objSchema(map[string]any{ "mail_id": strProp("线索中任意一封邮件的 ID"), + "offset": numProp("分页偏移,续取时传上次返回的 next_offset"), "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"))}) + target := withQuery("/api/v1/agent/mail/"+id+"/thread", map[string]string{ + "session_id": strings.TrimSpace(argStr(args, "session_id")), + "offset": argStr(args, "offset"), + }) 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) @@ -113,13 +117,27 @@ func (t *Tools) SendMail() Tool { "reply_to": strProp("回复某封邮件时传其 mail_id"), "session_alias": strProp("可选:指定会话别名"), "attachment_ids": arrayProp("附件 ID 列表(先用 upload_attachment 取得)"), + "propose_alias": strProp("可选。建议把当前会话改名成这个别名 —— 这只是建议," + + "别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new"), + "propose_reason": strProp("改名理由,一句话,展示给用户看"), }, "to"), }, run: func(ctx context.Context, args map[string]any) (string, error) { + // 改名提议搭在正文里发(服务端**没有** propose_alias 字段, + // 见 rename_proposal.go)。先判别名是否合法 —— 非法时**不追加标记** + // 并如实告诉模型「没提交」,而不是发一个服务端会静默丢弃的标记。 + wantAlias := strings.TrimSpace(argStr(args, "propose_alias")) + proposed := false + text := argStr(args, "body") + if wantAlias != "" { + var ok bool + text, ok = appendRenameProposal(text, wantAlias, argStr(args, "propose_reason")) + proposed = ok + } body := map[string]any{ "to": argStr(args, "to"), "subject": argStr(args, "subject"), - "body": argStr(args, "body"), + "body": text, } if v := argStr(args, "cc"); v != "" { body["cc"] = v @@ -134,7 +152,18 @@ func (t *Tools) SendMail() Tool { 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) + if code < 200 || code >= 300 { + return render("send_mail", code, payload, raw) + } + base, err := render("send_mail", code, payload, raw) + if err != nil { + return base, err + } + // 改名提议的回显必须用**服务端返回的**别名(见 renameProposalNote) + if note := renameProposalNote(str(payload, "rename_proposed"), wantAlias, proposed); note != "" { + return base + "\n" + note, nil + } + return base, nil }, } } @@ -148,11 +177,13 @@ func (t *Tools) ForwardMail() Tool { Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。", Annotations: writeSafe, InputSchema: objSchema(map[string]any{ - "mail_id": strProp("要转发的邮件 ID"), - "to": strProp("新收件人的三维地址"), - "comment": strProp("转发说明,置于引用原文之前"), - "subject": strProp("可选:自定义主题;留空则自动加 Fwd: 前缀"), - "cc": strProp("抄送,逗号分隔多个三维地址"), + "mail_id": strProp("要转发的邮件 ID"), + "to": strProp("新收件人的三维地址"), + "comment": strProp("转发说明,置于引用原文之前"), + "subject": strProp("可选:自定义主题;留空则自动加 Fwd: 前缀"), + "cc": strProp("抄送,逗号分隔多个三维地址"), + "session_alias": strProp("可选:给转发出来的新会话命名(仅在目标以 .new 结尾时生效)"), + "session_id": sessionIDProp, }, "mail_id", "to"), }, run: func(ctx context.Context, args map[string]any) (string, error) { @@ -167,7 +198,14 @@ func (t *Tools) ForwardMail() Tool { if v := argStr(args, "cc"); v != "" { body["cc"] = v } + if v := argStr(args, "session_alias"); v != "" { + body["session_alias"] = v + } target := "/api/v1/mail/" + id + "/forward" + // session_id 走 query(ForwardMail 与其它读端点一样用 agentScope 收窄) + target = withQuery(target, map[string]string{ + "session_id": strings.TrimSpace(argStr(args, "session_id")), + }) 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)