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:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user