feat(mcp): 补齐与四桥的三个参数缺口 —— 改名提议 / 线索翻页 / 转发命名

## 起因

做 MCP 与各桥的**参数级**对照(不是数量级)时,发现三处缺口。上一轮我
说过其中两处「服务端没有」—— **那是错的**,是我没查就下的结论:

| 缺口 | 真相 |
|---|---|
| `send_mail` 缺 `propose_alias` / `propose_reason` | ✅ 真缺口,且**不需要服务端字段** |
| `read_thread` 缺 `offset` | 服务端**早已支持**(`thread.go` 的 `intQuery(r,"offset",…)`),我漏传 |
| `forward_mail` 缺 `session_alias` / `session_id` | 服务端**早已支持**(`forward.go:33`),我漏传 |

三处都是「接上就行」,没有一处需要改服务端。

## ① 改名提议:为什么不是加个字段

`/mail/send` **没有** `propose_alias` 字段 —— 提议是**搭在正文里**发出去的:

    <!-- agentmail:rename-session alias="fix-login-leak" reason="定位到泄漏点" -->

服务端用正则摘出来、把标记从入库正文剥掉、把规范化后的别名回填到响应的
`rename_proposed`。载体选 HTML 注释的三个理由见 `lib/rename-proposal.js`:
react-markdown 默认不解析 raw HTML(没剥掉也不破版)、纯文本客户端里一行不碍事、
不与 Markdown 语法冲突。

所以在 Go 侧复刻了 `lib/rename-proposal.js`(三方插件共用那份)的三段逻辑:
`isProposableAlias` / `appendRenameProposal` / `renameProposalNote`。

## ★ 这一层的真正风险:跨语言镜像

格式差一个空格(或把双引号写成单引号),服务端正则就匹配不上,而**失败是
静默**的:邮件照常发出、提议凭空消失、模型以为自己提过了、下一封拿那个不存在的
别名寻址 → 404。

判据因此钉两件事:

- **能被服务端那个正则真的解出来** —— 直接 import `renameProposalRe`,
  不是另写一个(复制一份就放弃了「镜像」的意义)。
- **与 JS 版逐字节相同** —— 三个用例(含「理由里的双引号要去掉」)逐字符对照。

## ②③ 线索翻页与转发命名

`read_thread` 透传 `offset`(长线索不再只能拿首段);`forward_mail` 透传
`session_alias`(给转发出的新会话命名)与 `session_id`(与其它读端点一样过
`agentScope` 收窄)。

## 一处**故意**与桥不同的差异

`connect_to_server` 在桥侧有 `gateway_url` / `key_token`,MCP 侧保持无参 ——
网关内建端点**已认证**,改坐标是部署动作,不该由一次工具调用触发(桥侧能改是
因为它是局外进程)。判据里为此写了注释,防止将来有人"顺手补齐"。

## 判据(rename_proposal_test.go)

参数级对照那格钉「与其它桥逐字一致」——**缺参数不会报错**,只会让模型以为
该能力不存在,属静默缺陷。

**变异验证**:

    删掉 propose_alias 两行(回到缺口态)    → 红 1 ✓
    标记少一对引号(跨语言镜像写错)         → 红 2 ✓
    非法别名也追加标记(静默丢弃的来源)     → 红 2 ✓

第三条最要紧:别名不合法时**必须**不追加标记,否则发出一个服务端匹配得上却被
`validateSessionAlias` 拒掉的标记 —— 失败仍然是静默的。

## 两次判据自身缺陷(都记下来)

1. 「与 JS 版逐字节相同」那格最初用 Go 字符串字面量写期望值,`\n` 成了字面两字符
   ⇒ 判据错报红。代码是对的,判据错了。
2. 变异脚本只切掉 `strProp(…)` 的**第一行**、续行留在原地 ⇒ schema 仍合法 ⇒
   「0 红」。**没有采信那个 0**,改用完整锚点重测才拿到正确的红 1。

全量 14 包绿。
This commit is contained in:
2026-10-02 14:37:09 +08:00
parent d09ef395b4
commit 5e312c6f5f
3 changed files with 323 additions and 8 deletions

View File

@ -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)<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\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<!-- agentmail:rename-session alias=%q%s -->", 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)
}

View File

@ -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 + `<!-- agentmail:rename-session alias="fix-a" reason="理由" -->`},
{"正文", "fix-a", "", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" -->`},
// 理由里的双引号要去掉(HTML 注释里没有转义机制)
{"正文", "fix-a", `他说"好"`, "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="他说好" -->`},
}
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

View File

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