## 起因 DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。 三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它 必须先知道 path —— 但 path 恰恰是调用方无从得知的: 给 name → 只给 path(要再调一次才知道有哪些会话) 给 name+path → 给会话别名(但 path 得先猜对) 于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题 为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。 ## 修法 **① A 项:`flatten=1` 一次给出全部可投递地址** `SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带 `path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼, 拼错就是那个「猜错比报错更糟」。 可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name 匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。 按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信 仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。 **② B/C 项:标注而非隐藏** `paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。 选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`) 确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是 其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条 是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。 `suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序 (刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。 ## ★★ 顺带修掉一个生产级缺陷(实测撞出来的) 给 `SessionCandidate` 加 `LastActivity` 时用了: COALESCE(s.updated_at, '0001-01-01 00:00:00+00') COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan` ⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。 生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。 而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」—— 本次是我自己加的列触发的,排查花了几步。 改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。 注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。 ## MCP 侧同步 `suggest_address` 加 `flatten` 参数,且**渲染必须单独写**: flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句 「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。 path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径 (`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。 ## 判据(8 格) 含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、 「两个工作区都要出现」(原形状缺的就是这一维)、 「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、 「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。 **变异验证**: COALESCE 兜底(那个真 bug) → 红 1 ✓ flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path") ## 实测校准了一处报告里的数字 报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** —— 服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是 「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建, 恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。 §E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。 全量 14 包绿。
415 lines
17 KiB
Go
415 lines
17 KiB
Go
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"),
|
||
"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")),
|
||
"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)
|
||
},
|
||
}
|
||
}
|
||
|
||
// ---- 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 取得)"),
|
||
"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": text,
|
||
}
|
||
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))
|
||
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
|
||
},
|
||
}
|
||
}
|
||
|
||
// ---- 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("抄送,逗号分隔多个三维地址"),
|
||
"session_alias": strProp("可选:给转发出来的新会话命名(仅在目标以 .new 结尾时生效)"),
|
||
"session_id": sessionIDProp,
|
||
}, "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
|
||
}
|
||
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)
|
||
},
|
||
}
|
||
}
|
||
|
||
// ---- 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@path.session` —— 拼错不会报错," +
|
||
"只会静默投进另一条会话(实测:`pi@root` 与 `pi@/root` 是两个不同工作区," +
|
||
"选错后投递返回 200 但落进无关线索)。三种用法:" +
|
||
"只给 name(列出该 name 用过的工作目录,并标注哪些是桥内部目录、哪些是相对路径);" +
|
||
"给 name + flatten=true(**一次拿到该 name 的全部可投递地址**,含各自的工作区 —— " +
|
||
"不知道在哪个工作区时用这个);" +
|
||
"给 name + path(列该工作区下可续谈的会话)。",
|
||
Annotations: readOnly,
|
||
InputSchema: objSchema(map[string]any{
|
||
"name": strProp("收件人名;留空则列出所有候选收件人"),
|
||
"path": strProp("工作目录;与 name 同时给出才列会话"),
|
||
"flatten": boolProp("★ 只给 name 时置 true:一次返回该 name 的全部可投递地址(含各自工作区)"),
|
||
}),
|
||
},
|
||
run: func(ctx context.Context, args map[string]any) (string, error) {
|
||
flat := "0"
|
||
if argBool(args, "flatten") {
|
||
flat = "1"
|
||
}
|
||
target := withQuery("/api/v1/agent/contacts/suggest", map[string]string{
|
||
"flatten": flat,
|
||
"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
|
||
},
|
||
}
|
||
}
|