feat(mcp): MCP 集成进网关本体 —— POST /api/v1/mcp(Streamable HTTP)
## 为什么要集成而不是独立进程
上一版(29ad8aa)是独立进程 `plugins/zcode-mail-bridge/mcp/server.mjs`,
用 HTTP 调本网关。四条真实成本:
1. **工具语义有两份**。桥里的 read_inbox / send_mail 是**手抄**网关的,
抄错就是行为分叉 —— 已抓到两次:`connect_to_server` 只发
`X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
secret ⇒ secret-only 的 Agent 必然 400。
2. **鉴权与收窄要再实现一遍**。工作区收窄、会话收窄、冷静期、配额住在服务端。
3. **多一跳 + 多一个故障点**。
4. **接入端仍要装东西**(node + 桥 + 环境变量)。
现在:工具**包装现有 handler**,同一份代码、同一套鉴权与收窄;
接入端只填一个 URL。
## 传输与实现(用户裁定)
- **Streamable HTTP**(规范 2025-06-18):单端点 POST,通知回 202,
请求回 JSON-RPC。
- **包装 handler**(不是直调 repo):`newRequest` + `invoke` 造内部请求
交给 `handler.GetInbox` / `SendMail` / … 于是 `AgentMayReadSession`、
冷静期、配额、附件保护目录全部是同一条代码路径,不是复述。
- 手写零依赖 JSON-RPC(协议面只有 4 个方法),与本仓取向一致。
端点挂在 `AgentAuth` **之内**:必须与 /mail/send 同一套凭证,
否则就成了绕过收窄的旁门。
## 11 个工具,名字与参数与四桥逐字一致
`connect_to_server` 在这里只做一次真实读来确认连通性 —— 能调到它本身
就证明凭证已过(它是局内端点,不再需要 register)。
## ★ 端到端撞出并修掉的两个真 bug
**① `Tool.Run` 丢掉了身份**(本来写成 `context.Background()`)。
症状:每个工具调用都 Unauthorized,模型表现为「说连上了但读不到任何信」。
**② 路径参数没到位**:被包装的 handler 用 `chi.URLParam(r,"id")` 取 id,
而 `httptest.NewRequest` 造的请求**没过 chi 的路由** ⇒ `URLParam` 恒空
⇒ 任何带路径参数的工具都报「Invalid id」。
第②个的发现过程值得记:端到端测越权时,主人和越权者**都**返回
「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把**正确的收窄改松**;
做对照才看出是参数没到位。
修法两处:`withRouteParams` 注入 chi RouteContext;`invoke` 里**不能**再
`WithContext(ctx)` —— 那会覆盖掉刚注入的 RouteContext。
**③ 发现并暴露了会话越权漏洞**(同批,单独提交 095213b):
`AgentMayReadSession` 只比 `scope == target`,不问「你是不是参与方」,
而 session_id 由请求方给。对照实验 + 生产复核证实可读他人正文。
## 判据(13 格)
`internal/mcp/mcp_test.go`。真正在钉三件**只有集成才可能坏**的事:
1. MCP 不能成为越权旁门(工具参数里没有身份字段)。
2. 参数映射不许偷偷放宽/收紧(`attachment_ids` 被吞 ⇒ 附件静默不随信发出)。
3. 协议语义不许退化(工具失败必须 result+isError,不是 JSON-RPC error)。
`TestEveryErrorResponseCarriesID` 是被真 bug 逼出来的:曾用
`ID json.RawMessage` + `omitempty`,nil 时**整个 id 字段从 JSON 里消失**,
客户端会一直等这条的响应。遍历全部错误出口逐条验。
**变异验证**:
Run 丢身份 → 红 4
工具失败回 JSON-RPC error → 红 4
read_inbox 丢 workspace 收窄 → 红 1
id 泄露(tag+idPtr 同时失效) → 红 1 ★(真 bug 需两处同时失效,故两处防御都要留)
去掉 withRouteParams → 红 1
invoke 里加回 WithContext → 红 1
## 端到端(真实网关进程,临时库,备用端口 8199,不动生产)
未认证 /mcp → 401
错误密钥 → 401
initialize → 回显 2025-06-18
notifications/initialized→ 202 且无响应体
tools/list → 11 个,带 annotations 与 required
send_mail → read_inbox → mcp-peer 通过 MCP 读到对方发来的信
read_mail(带 session_id)→ 主人读到自己的信
## 未做
- 未删除旧桥 `plugins/zcode-mail-bridge/mcp/server.mjs`。它是 zcode 插件
清单里声明的入口(`.zcode-plugin/plugin.json` 的 mcpServers),删掉会破坏
该插件的组装。两者并存无害:桥仍走 HTTP,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
This commit is contained in:
@ -14,6 +14,7 @@ import (
|
||||
"github.com/agentmail/gateway/internal/config"
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/handler"
|
||||
"github.com/agentmail/gateway/internal/mcp"
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/push"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
@ -73,6 +74,13 @@ func main() {
|
||||
scheduler.Start()
|
||||
defer scheduler.Stop()
|
||||
|
||||
// MCP 端点(Streamable HTTP)。工具**包装现有 handler**,所以工具语义
|
||||
// 与 /api/v1/agent/* 完全同一份代码 —— 不存在第二套收窄或配额逻辑。
|
||||
mcpServer := mcp.NewServer(nil)
|
||||
mcp.NewTools().RegisterAll(mcpServer)
|
||||
mcpHandler := mcpServer.HandleHTTP
|
||||
log.Printf("MCP 端点就绪:POST /api/v1/mcp(%d 个工具)", mcpServer.ToolCount())
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(chimw.Logger)
|
||||
@ -116,6 +124,16 @@ func main() {
|
||||
// ---- Agent 侧(X-Agent-Name + X-Agent-Secret) ----
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.AgentAuth)
|
||||
|
||||
// ★ 2026-10-02:MCP 端点。挂在 AgentAuth **之内**是刻意的 ——
|
||||
// 它必须与 /mail/send、/mail/inbox 走同一套凭证,否则就成了
|
||||
// 绕过工作区收窄与会话收窄的旁门(那正是 2026-10-02 修掉的
|
||||
// 越权形状)。工具内部包装的就是下面这些 handler。
|
||||
//
|
||||
// 这样接入端只需填一个 URL(Streamable HTTP 的 POST),不用装插件、
|
||||
// 不用起进程、不用配环境变量。
|
||||
r.Post("/mcp", mcpHandler)
|
||||
|
||||
r.Post("/agent/heartbeat", handler.HeartbeatAgent)
|
||||
r.Post("/mail/send", handler.SendMail)
|
||||
r.Get("/mail/inbox", handler.GetInbox)
|
||||
|
||||
541
server/internal/mcp/mcp_test.go
Normal file
541
server/internal/mcp/mcp_test.go
Normal file
@ -0,0 +1,541 @@
|
||||
package mcp
|
||||
|
||||
// MCP 端点的判据。
|
||||
//
|
||||
// # 这些判据真正在钉什么
|
||||
//
|
||||
// MCP 工具**包装现有 handler**,所以它成立的唯一依据是「与 HTTP 端点
|
||||
// 同一套语义」。那就不必重新测一遍业务规则(那些在 handler 自己的测试里),
|
||||
// 而要钉三件**只有集成才可能坏**的事:
|
||||
//
|
||||
// 1. **MCP 不能成为越权旁门**。工具参数里没有身份字段;身份只能来自
|
||||
// AgentAuth 放进的 context。若某处改成从参数取身份,任何人都能冒充
|
||||
// 别的 Agent —— 这正是 2026-10-02 修掉的 `AgentMayReadSession`
|
||||
// `if scope == nil { return true }` 那种失败模式(默认放行)。
|
||||
// 2. **参数映射不许偷偷放宽/收紧**。例如 send_mail 的 `attachment_ids`
|
||||
// 若被吞掉,附件会静默不随信发出;workspace 若被丢掉,GetInbox 会 400。
|
||||
// 3. **协议语义不许退化**。工具失败必须回 result+isError 而不是
|
||||
// JSON-RPC error(否则模型只看到「协议错误」,拿不到原因就没法改道)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := db.Connect(context.Background(), filepath.Join(dir, "mcp-test.db")); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// newTestServer 起一个挂了全部工具的端点。
|
||||
func newTestServer() *Server {
|
||||
s := NewServer(log.New(os.Stderr, "", 0))
|
||||
NewTools().RegisterAll(s)
|
||||
return s
|
||||
}
|
||||
|
||||
// call 走完整 HTTP 路径调一个工具(这样才是端到端,不是直接调 Run)。
|
||||
func call(t *testing.T, s *Server, agent, tool string, args map[string]any) (int, *rpcMessage) {
|
||||
t.Helper()
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{"name": tool, "arguments": args},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(string(payload)))
|
||||
if agent != "" {
|
||||
r = r.WithContext(context.WithValue(r.Context(), middleware.AgentNameKey, agent))
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
s.HandleHTTP(rec, r)
|
||||
|
||||
var msg rpcMessage
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
|
||||
return rec.Code, &msg
|
||||
}
|
||||
|
||||
// toolText 取出 tools/call 的文本内容。
|
||||
func toolText(t *testing.T, msg *rpcMessage) (string, bool) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(msg.Result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
var tr toolResult
|
||||
if err := json.Unmarshal(raw, &tr); err != nil {
|
||||
t.Fatalf("result 不是 toolResult:%v", err)
|
||||
}
|
||||
if len(tr.Content) == 0 {
|
||||
return "", tr.IsError
|
||||
}
|
||||
return tr.Content[0].Text, tr.IsError
|
||||
}
|
||||
|
||||
// seedAgentAndMail 建一个 Agent 与一封寄给它的邮件。
|
||||
func seedAgentAndMail(t *testing.T, agent, workspace, subject string) (string, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO agents (agent_name, secret, platform, default_rounds) VALUES ($1, 'x', 'test', 50)`,
|
||||
agent); err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
fromName, alias := "gui-lab", "s1"
|
||||
mailID := repoInsertMail(t, ctx, agent, fromName, workspace, alias, subject)
|
||||
var sessionID string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT session_id FROM mails WHERE mail_id=$1`, mailID).Scan(&sessionID); err != nil {
|
||||
t.Fatalf("取 session_id: %v", err)
|
||||
}
|
||||
_ = fromName
|
||||
return mailID, fromName
|
||||
}
|
||||
|
||||
// sessionOf 取一封信所属的会话。
|
||||
func sessionOf(t *testing.T, mailID string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := db.DB.QueryRowContext(context.Background(),
|
||||
`SELECT session_id FROM mails WHERE mail_id=$1`, mailID).Scan(&id); err != nil {
|
||||
t.Fatalf("sessionOf: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ---- 协议层 ----
|
||||
|
||||
func TestInitializeEchoesClientVersion(t *testing.T) {
|
||||
s := newTestServer()
|
||||
payload := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(payload))
|
||||
rec := httptest.NewRecorder()
|
||||
s.HandleHTTP(rec, r)
|
||||
|
||||
var msg rpcMessage
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
|
||||
if msg.Error != nil {
|
||||
t.Fatalf("initialize 不该报错:%v", msg.Error)
|
||||
}
|
||||
res := msg.Result.(map[string]any)
|
||||
if got := res["protocolVersion"]; got != "2025-06-18" {
|
||||
t.Errorf("协议版本应回显客户端给的 2025-06-18,收到 %v", got)
|
||||
}
|
||||
// 反向对照:缺版本时用回退值,不崩
|
||||
payload = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
|
||||
rec = httptest.NewRecorder()
|
||||
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(payload)))
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
|
||||
if msg.Result.(map[string]any)["protocolVersion"] == nil {
|
||||
t.Error("缺 protocolVersion 时应回退一个值,而不是省略该字段")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsGetNoResponse(t *testing.T) {
|
||||
s := newTestServer()
|
||||
// 没有 id = 通知。回了响应,客户端会把响应与请求错配。
|
||||
for _, m := range []string{
|
||||
`{"jsonrpc":"2.0","method":"notifications/initialized"}`,
|
||||
`{"jsonrpc":"2.0","method":"tools/list"}`,
|
||||
`{"jsonrpc":"2.0","method":"ping"}`,
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(m)))
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Errorf("通知 %s:期望 202,收到 %d(并带响应体 %q)", m, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsListIsStableAndCarriesAnnotations(t *testing.T) {
|
||||
s := newTestServer()
|
||||
var names []string
|
||||
for i := 0; i < 5; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)))
|
||||
var msg rpcMessage
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &msg)
|
||||
list := msg.Result.(map[string]any)["tools"].([]any)
|
||||
names = names[:0]
|
||||
for _, it := range list {
|
||||
names = append(names, it.(map[string]any)["name"].(string))
|
||||
}
|
||||
if i == 0 {
|
||||
// annotations 必须透传:宿主据此算风险等级并在 plan 档放行。
|
||||
// 漏传的后果不是「少个提示」,而是工具在该档下全被拒。
|
||||
found := false
|
||||
for _, it := range list {
|
||||
m := it.(map[string]any)
|
||||
if m["name"] == "read_inbox" {
|
||||
if _, ok := m["annotations"]; !ok {
|
||||
t.Error("read_inbox 缺 annotations")
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("tools/list 里没有 read_inbox")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 顺序必须稳定:map 迭代随机会让客户端每次刷新看到不同排列
|
||||
if len(names) == 0 {
|
||||
t.Fatal("tools/list 为空")
|
||||
}
|
||||
for j := 1; j < len(names); j++ {
|
||||
if names[j] < names[j-1] {
|
||||
t.Fatalf("工具顺序不稳定:%v", names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownToolIsInvalidParamsNotExecutionFailure(t *testing.T) {
|
||||
s := newTestServer()
|
||||
_, msg := call(t, s, "a", "no_such_tool", nil)
|
||||
if msg.Error == nil {
|
||||
t.Fatal("未知工具名应回 JSON-RPC error")
|
||||
}
|
||||
if msg.Error.Code != rpcInvalidParams {
|
||||
t.Errorf("期望 INVALID_PARAMS(%d),收到 %d", rpcInvalidParams, msg.Error.Code)
|
||||
}
|
||||
// 错误文案要告诉模型可用集合,否则它只能瞎试
|
||||
if !strings.Contains(msg.Error.Message, "read_inbox") {
|
||||
t.Errorf("未知工具的错误应列出可用工具,收到 %q", msg.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolFailureIsResultIsErrorNotRPCError(t *testing.T) {
|
||||
setupDB(t)
|
||||
s := newTestServer()
|
||||
// 不存在的 Agent + 非法 workspace ⇒ handler 报错。
|
||||
// 关键不是失败本身,而是失败**怎么**回。
|
||||
_, msg := call(t, s, "ghost", "read_inbox", map[string]any{"workspace": "not-absolute"})
|
||||
if msg.Error != nil {
|
||||
t.Fatalf("工具失败不该回 JSON-RPC error(模型就看不到原因了),收到 %v", msg.Error)
|
||||
}
|
||||
text, isErr := toolText(t, msg)
|
||||
if !isErr {
|
||||
t.Error("失败时必须带 isError:true")
|
||||
}
|
||||
if text == "" {
|
||||
t.Error("失败时必须给出原因文本")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodNotAllowedOnNonPost(t *testing.T) {
|
||||
s := newTestServer()
|
||||
rec := httptest.NewRecorder()
|
||||
s.HandleHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET /mcp 应 405,收到 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedJSONGetsParseError(t *testing.T) {
|
||||
s := newTestServer()
|
||||
rec := httptest.NewRecorder()
|
||||
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(`{oops`)))
|
||||
if !strings.Contains(rec.Body.String(), `"id":null`) {
|
||||
t.Errorf("解析错误响应必须带 id:null,收到 %q", rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), fmt.Sprintf("%d", rpcParse)) {
|
||||
t.Errorf("非法 JSON 应回 PARSE_ERROR(%d),收到 %q", rpcParse, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 工具语义与 HTTP 端点同源 ----
|
||||
|
||||
func TestReadInboxRequiresWorkspaceSameAsHTTP(t *testing.T) {
|
||||
setupDB(t)
|
||||
seedAgentAndMail(t, "probe", "/tmp/ws", "一封信")
|
||||
|
||||
s := newTestServer()
|
||||
// 不带 workspace ⇒ 400(这是 GetInbox 的既有语义,包装后必须一致)
|
||||
_, msg := call(t, s, "probe", "read_inbox", map[string]any{})
|
||||
if msg.Error != nil {
|
||||
t.Fatalf("工具失败不该是协议错误:%v", msg.Error)
|
||||
}
|
||||
text, isErr := toolText(t, msg)
|
||||
if !isErr {
|
||||
t.Errorf("缺 workspace 时 read_inbox 必须失败,收到 %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "workspace") {
|
||||
t.Errorf("错误文案应说清是 workspace 的问题,收到 %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInboxScopedToOwnWorkspace(t *testing.T) {
|
||||
setupDB(t)
|
||||
ctx := context.Background()
|
||||
// 同一个 Agent 在两个工作区各有信;按工作区收窄后只能看到自己那个的
|
||||
seedAgentAndMail(t, "probe", "/tmp/wsA", "A 区的信")
|
||||
repoInsertMail(t, ctx, "probe", "gui-lab", "/tmp/wsB", "sB", "B 区的信")
|
||||
|
||||
s := newTestServer()
|
||||
_, msg := call(t, s, "probe", "read_inbox", map[string]any{"workspace": "/tmp/wsA"})
|
||||
text, isErr := toolText(t, msg)
|
||||
if isErr {
|
||||
t.Fatalf("read_inbox 失败:%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "A 区的信") {
|
||||
t.Errorf("应看到 A 区的信,收到 %q", text)
|
||||
}
|
||||
if strings.Contains(text, "B 区的信") {
|
||||
t.Errorf("★ 收窄失效:看到了别的工作区的信(B 区的信),输出 %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCannotReadOthersMail(t *testing.T) {
|
||||
setupDB(t)
|
||||
ctx := context.Background()
|
||||
// 别人的信
|
||||
otherMailID, _ := seedAgentAndMail(t, "victim", "/tmp/ws", "别人的信")
|
||||
|
||||
s := newTestServer()
|
||||
// 攻击者身份,读别人的信
|
||||
_, msg := call(t, s, "attacker", "read_mail", map[string]any{"mail_id": otherMailID})
|
||||
text, isErr := toolText(t, msg)
|
||||
if !isErr {
|
||||
t.Errorf("★ 越权:attacker 读到了 victim 的邮件,输出 %q", text)
|
||||
}
|
||||
if strings.Contains(text, "别人的信") {
|
||||
t.Errorf("★ 越权:泄露了别人的正文 %q", text)
|
||||
}
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
func TestToolArgsCannotForgeIdentity(t *testing.T) {
|
||||
setupDB(t)
|
||||
// 传 identity 相关的参数不应影响权限判定
|
||||
s := newTestServer()
|
||||
_, msg := call(t, s, "attacker", "read_inbox", map[string]any{
|
||||
"workspace": "/tmp/ws",
|
||||
"agent_name": "victim",
|
||||
"X-Agent-Name": "victim",
|
||||
"from": "victim",
|
||||
})
|
||||
// 受害者不在这个工作区/或不存在时应当看到「空」而不是别人的信
|
||||
text, _ := toolText(t, msg)
|
||||
if strings.Contains(text, "别人的信") {
|
||||
t.Errorf("参数里的身份字段不该影响鉴权,收到 %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 参数映射 ----
|
||||
|
||||
func TestAttachmentIDsSurviveMapping(t *testing.T) {
|
||||
setupDB(t)
|
||||
_, _ = seedAgentAndMail(t, "probe", "/tmp/ws", "占位")
|
||||
|
||||
s := newTestServer()
|
||||
// 用受配额的发信路径发一封带附件的信(附件 id 传下去)
|
||||
_, msg := call(t, s, "probe", "send_mail", map[string]any{
|
||||
"to": "gui-lab@/tmp.s1",
|
||||
"subject": "带附件",
|
||||
"body": "见附件",
|
||||
"attachment_ids": []any{"att-does-not-exist"},
|
||||
})
|
||||
text, isErr := toolText(t, msg)
|
||||
if !isErr {
|
||||
t.Fatalf("传了不存在的附件 id,send_mail 理应失败,收到 %q", text)
|
||||
}
|
||||
// 附件 id 真被记到那封信上
|
||||
// 发信时被拒的 attachment_id 必须在错误里出现(说明映射到了、校验才起效)。
|
||||
// 换一个不存在的 id 会被 SendMail 拒绝,而拒绝原因正是参数映射是否生效的直接证据。
|
||||
if !strings.Contains(text, "attachment") {
|
||||
t.Errorf("★ attachment_ids 似乎没被映射过去(错误里没提附件):%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingArgumentsTreatedAsEmpty(t *testing.T) {
|
||||
setupDB(t)
|
||||
seedAgentAndMail(t, "probe", "/tmp/ws", "信")
|
||||
s := newTestServer()
|
||||
// suggest_address 无参 ⇒ 返回候选名,不该崩
|
||||
_, msg := call(t, s, "probe", "suggest_address", nil)
|
||||
if msg.Error != nil {
|
||||
t.Fatalf("无参 suggest_address 不该是协议错误:%v", msg.Error)
|
||||
}
|
||||
_, isErr := toolText(t, msg)
|
||||
if isErr {
|
||||
t.Errorf("无参 suggest_address 不该失败")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCountAndNoExecutionTools(t *testing.T) {
|
||||
s := newTestServer()
|
||||
// 11 个邮件工具,不含任何"会动机器"的工具
|
||||
if got := s.ToolCount(); got != 11 {
|
||||
t.Errorf("工具数应为 11(只读+收发邮件),收到 %d", got)
|
||||
}
|
||||
for _, name := range []string{"run_command", "write_file", "exec", "shell"} {
|
||||
if _, ok := s.tools[name]; ok {
|
||||
t.Errorf("★ 通用 MCP 面不得提供执行类工具 %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 辅助 ----
|
||||
|
||||
func repoInsertMail(t *testing.T, ctx context.Context, to, from, workspace, alias, subject string) string {
|
||||
t.Helper()
|
||||
sessionID := repoEnsureSession(t, ctx, to, from, workspace, alias)
|
||||
var mailID string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO mails (session_id, from_name, to_name, subject, body, status)
|
||||
VALUES ($1,$2,$3,$4,'正文',$5) RETURNING mail_id`,
|
||||
sessionID, from, to, subject, "unread").Scan(&mailID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert mail: %v", err)
|
||||
}
|
||||
return mailID
|
||||
}
|
||||
|
||||
func repoEnsureSession(t *testing.T, ctx context.Context, to, from, workspace, alias string) string {
|
||||
t.Helper()
|
||||
t.Helper()
|
||||
var sessionID string
|
||||
// 收窄走 s.workspace(见 repo.workspaceScope),所以工作区建会话时就得给对
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent) VALUES ($1,'t','active',$2,$3)
|
||||
RETURNING session_id`, alias, workspace, to).Scan(&sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert session: %v", err)
|
||||
}
|
||||
_ = from
|
||||
return sessionID
|
||||
}
|
||||
|
||||
// repoInsertAttachment 造一个附件。upload_attachment 走 blob 存储,
|
||||
// 这里只需要一个**已存在**的 attachment_id 来验证参数映射。
|
||||
func repoInsertAttachment(t *testing.T, mailID string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := db.DB.QueryRowContext(context.Background(),
|
||||
`INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
|
||||
VALUES ($1,'probe','a.txt','text/plain',12,'deadbeef') RETURNING attachment_id`, mailID).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("insert attachment: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// repoAttachmentIDsOfMail 取一封信挂着的附件 id(attachments.mail_id)。
|
||||
func repoAttachmentIDsOfMail(t *testing.T, mailID string) string {
|
||||
t.Helper()
|
||||
var ids string
|
||||
err := db.DB.QueryRowContext(context.Background(),
|
||||
`SELECT COALESCE(group_concat(attachment_id),'') FROM attachments WHERE mail_id=$1`, mailID).Scan(&ids)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
var _ = repo.ListInboxScoped // 保持 import(工具链会用到 handler,repo 只在测试里用到)
|
||||
|
||||
// TestEveryErrorResponseCarriesID 钉住 JSON-RPC 的硬要求:**每个**响应都要带 id。
|
||||
//
|
||||
// 为什么单独写一格:解析错误那格只覆盖了「解析失败」一条路径。曾用
|
||||
// `ID json.RawMessage` + `omitempty`,nil 时整个 id 字段会从 JSON 里消失 ——
|
||||
// 客户端拿不到配对依据,会一直等这条的响应。这格遍历所有错误出口,
|
||||
// 任何一条漏掉 id 都会红。
|
||||
func TestEveryErrorResponseCarriesID(t *testing.T) {
|
||||
s := newTestServer()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string // 期望的 id(JSON 字面量)
|
||||
}{
|
||||
{"解析失败", `{oops`, "null"},
|
||||
{"jsonrpc 版本不对", `{"jsonrpc":"1.0","id":7,"method":"ping"}`, "7"},
|
||||
{"方法不存在", `{"jsonrpc":"2.0","id":7,"method":"no/such"}`, "7"},
|
||||
{"工具名未知", `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nope"}}`, "7"},
|
||||
{"工具名缺失", `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{}}`, "7"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
s.HandleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(c.body)))
|
||||
out := rec.Body.String()
|
||||
|
||||
// 直接查 JSON 里有没有 id 键(而不是靠字符串匹配,那样会漏掉
|
||||
// `"id":null` 这种情形)
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Errorf("%s:响应不是合法 JSON:%v(%q)", c.name, err, out)
|
||||
continue
|
||||
}
|
||||
rawID, ok := decoded["id"]
|
||||
if !ok {
|
||||
t.Errorf("★ %s:响应缺少 id 字段 ⇒ 客户端无法配对,会一直等(收到 %q)", c.name, out)
|
||||
continue
|
||||
}
|
||||
if string(rawID) != c.want {
|
||||
t.Errorf("%s:id 应为 %s,收到 %s", c.name, c.want, string(rawID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathParamsReachHandlers 钉住「包装 handler 时路径参数必须到位」。
|
||||
//
|
||||
// ★ 这格是端到端撞出来的:主人读自己的信与越权者读别人的信**都**返回
|
||||
// 「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把正确的收窄改松;
|
||||
// 做对照才看出是 `chi.URLParam` 拿不到值(httptest 请求没过 chi 的路由)。
|
||||
//
|
||||
// 判据用「主人能读自己的信」当锚:它同时证明参数到位**且**收窄没有误伤。
|
||||
func TestPathParamsReachHandlers(t *testing.T) {
|
||||
setupDB(t)
|
||||
mailID, _ := seedAgentAndMail(t, "owner", "/tmp/ws", "主人自己的信")
|
||||
sessionID := sessionOf(t, mailID)
|
||||
|
||||
s := newTestServer()
|
||||
// agentScope 要求 session_id 与信所属会话一致(这是**正确**的收窄,
|
||||
// 不是路径参数问题)—— 所以这里带上它。
|
||||
args := map[string]any{"mail_id": mailID, "session_id": sessionID}
|
||||
text, isErr := toolText(t, callMsg(t, s, "owner", "read_mail", args))
|
||||
if isErr {
|
||||
t.Fatalf("★ 主人读自己的信应该成功,却失败了(路径参数没到位?):%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "主人自己的信") {
|
||||
t.Errorf("应读到自己的信,收到 %q", text)
|
||||
}
|
||||
|
||||
// read_thread 同样带路径参数,一并钉住
|
||||
text, isErr = toolText(t, callMsg(t, s, "owner", "read_thread", args))
|
||||
if isErr {
|
||||
t.Errorf("★ read_thread 也应成功(同一类路径参数):%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// callMsg 是 call 的单值版本(拿消息)。
|
||||
func callMsg(t *testing.T, s *Server, agent, tool string, args map[string]any) *rpcMessage {
|
||||
t.Helper()
|
||||
_, msg := call(t, s, agent, tool, args)
|
||||
return msg
|
||||
}
|
||||
360
server/internal/mcp/server.go
Normal file
360
server/internal/mcp/server.go
Normal file
@ -0,0 +1,360 @@
|
||||
// Package mcp 把 MCP(Model Context Protocol)实现进网关本身。
|
||||
//
|
||||
// # 为什么在服务端而不是独立进程
|
||||
//
|
||||
// 早先的形状是 `plugins/zcode-mail-bridge/mcp/server.mjs`:一个独立进程,
|
||||
// 用 HTTP 调本网关。这条路有四个真实成本:
|
||||
//
|
||||
// 1. **工具语义有两份**。桥里的 read_inbox / send_mail 是**手抄**网关的语义,
|
||||
// 抄错就是行为分叉。实测已经踩到过一次:`connect_to_server` 只发
|
||||
// `X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
|
||||
// secret ⇒ secret-only 的 Agent 调它必然 400。
|
||||
// 2. **鉴权与作用域要再实现一遍**。工作区收窄、会话收窄、冷静期、配额
|
||||
// 这些规则住在服务端;独立进程拿不到,只能靠 HTTP 重走一遍。
|
||||
// 3. **多一跳 + 多一个故障点**。宿主 → 桥进程 → HTTP → 网关。
|
||||
// 4. **接入端仍要装东西**。本机装 node、装桥、配环境变量。
|
||||
//
|
||||
// 进服务端之后:工具**包装现有 handler**(见 tools.go),同一条代码路径、
|
||||
// 同一套鉴权与收窄;宿主只需填一个 URL。
|
||||
//
|
||||
// # 传输:Streamable HTTP
|
||||
//
|
||||
// MCP 规范 2025-06-18 的传输:客户端 POST 一个 JSON-RPC 消息到单一端点,
|
||||
// 服务端回 202(无输出)或一条 SSE 流。单条请求-响应场景最简单的是
|
||||
// **直接回 JSON**(POST 一次拿到一个 JSON-RPC 响应),本实现这样做;
|
||||
// 会把 Accept 头里的 `text/event-stream` 也认下来,返回 `Content-Type:
|
||||
// application/json`(规范允许服务端在无待推送消息时如此)。
|
||||
//
|
||||
// 之所以不引 `github.com/modelcontextprotocol/go-sdk`:协议面只有四个方法,
|
||||
// 而引 SDK 会带来一条依赖链;与本仓其余部分零依赖的取向一致(见
|
||||
// server/go.mod)。手写让这一层成为可单测的纯函数。
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 协议版本。客户端报的版本原样回显(见 handleMessage 的说明)。
|
||||
const (
|
||||
ProtocolVersion = "2025-06-18"
|
||||
FallbackVersion = "2024-11-05"
|
||||
|
||||
ServerName = "agentmail"
|
||||
ServerVersion = "1.0.0"
|
||||
)
|
||||
|
||||
// JSON-RPC 错误码(只列本实现真的会返回的)。
|
||||
const (
|
||||
rpcParse = -32700
|
||||
rpcInvalidRequest = -32600
|
||||
rpcMethodNotFound = -32601
|
||||
rpcInvalidParams = -32602
|
||||
rpcInternal = -32603
|
||||
)
|
||||
|
||||
// rpcMessage 是 JSON-RPC 消息。请求与响应共用(协议本身如此),故不分类型。
|
||||
type rpcMessage struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
// ID 是指针而不是 RawMessage:协议规定**每个响应都必须带 id**,
|
||||
// 且解析失败时 id 必须是 JSON null。用 RawMessage 配 `omitempty` 时,
|
||||
// nil 会让整个字段消失 —— 实测过一次(TestMalformedJSONGetsParseError
|
||||
// 抓到的):响应里没有 id,客户端会一直等这条的响应。
|
||||
//
|
||||
// 指针的取舍:非空指针指向 RawMessage(可能是 `null`、数字、字符串);
|
||||
// nil 指针表示「无 id」(通知)。
|
||||
ID *json.RawMessage `json:"id"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// nullID 是 JSON-RPC 规定的「id 为 null」(解析失败时用)。
|
||||
var nullID = json.RawMessage("null")
|
||||
|
||||
// rawID 把指针化的 id 还原成 RawMessage(nil ⇒ nil)。
|
||||
func rawID(p *json.RawMessage) json.RawMessage {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// toolCallParams 是 `tools/call` 的参数。
|
||||
type toolCallParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// toolResult 是 `tools/call` 的结果。
|
||||
//
|
||||
// isError 的存在是**功能性的**:MCP 的约定是工具执行失败回 result +
|
||||
// isError:true,而不是 JSON-RPC error —— 后者模型只看到"协议错误",
|
||||
// 拿不到失败原因就没法改道(换个 attachment_id 重试之类)。
|
||||
type toolResult struct {
|
||||
Content []content `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
type content struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// textResult 造一条成功结果。
|
||||
func textResult(s string) toolResult {
|
||||
return toolResult{Content: []content{{Type: "text", Text: s}}}
|
||||
}
|
||||
|
||||
// errorResult 造一条失败结果(**不是** JSON-RPC error)。
|
||||
func errorResult(format string, a ...any) toolResult {
|
||||
return toolResult{
|
||||
Content: []content{{Type: "text", Text: fmt.Sprintf(format, a...)}},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// idPtr 把 nil 归一成「显式的 JSON null」—— 响应**必须**带 id 字段。
|
||||
func idPtr(id json.RawMessage) *json.RawMessage {
|
||||
if id == nil {
|
||||
return &nullID
|
||||
}
|
||||
return &id
|
||||
}
|
||||
|
||||
func result(id json.RawMessage, v any) *rpcMessage {
|
||||
return &rpcMessage{JSONRPC: "2.0", ID: idPtr(id), Result: v}
|
||||
}
|
||||
|
||||
func failure(id json.RawMessage, code int, format string, a ...any) *rpcMessage {
|
||||
return &rpcMessage{
|
||||
JSONRPC: "2.0",
|
||||
ID: idPtr(id),
|
||||
Error: &rpcError{Code: code, Message: fmt.Sprintf(format, a...)},
|
||||
}
|
||||
}
|
||||
|
||||
// Tool 是一次 MCP 工具调用。
|
||||
type Tool interface {
|
||||
// Schema 声明工具名、说明与入参 JSON Schema。
|
||||
Schema() ToolSchema
|
||||
// Run 执行。ctx 是**当前请求的 context**,里面带着 AgentAuth 放进来的
|
||||
// 身份(middleware.AgentNameKey)。
|
||||
//
|
||||
// ★ 为什么 ctx 必须显式传进来(而不是在实现里 context.Background()):
|
||||
// 身份就住在这个 ctx 里。丢掉它 ⇒ 每个工具调用都 Unauthorized,
|
||||
// 而症状是「模型说连上了但读不到任何信」—— 很难当场归因。
|
||||
// 这条是被判据逼出来的(TestReadInboxRequiresWorkspaceSameAsHTTP
|
||||
// 先是报 Unauthorized 才暴露出来)。
|
||||
Run(ctx context.Context, args map[string]any) (string, error)
|
||||
}
|
||||
|
||||
// ToolSchema 是 tools/list 里每个条目的形状。
|
||||
//
|
||||
// Annotations 是 MCP 规范里的提示字段(readOnlyHint / destructiveHint 等)。
|
||||
// 本实现**透传**它:部分宿主据此算风险等级并在 plan 档下放行非破坏性工具,
|
||||
// 漏传的后果不是"少个提示"而是工具在该档下全被拒。
|
||||
type ToolSchema struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
Annotations map[string]any `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// Server 是 MCP 端点。
|
||||
type Server struct {
|
||||
tools map[string]Tool
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// NewServer 造一个端点。
|
||||
func NewServer(logger *log.Logger) *Server {
|
||||
if logger == nil {
|
||||
logger = log.Default()
|
||||
}
|
||||
return &Server{tools: map[string]Tool{}, log: logger}
|
||||
}
|
||||
|
||||
// Register 注册一个工具。同名时后者覆盖前者(测试里常用)。
|
||||
func (s *Server) Register(t Tool) {
|
||||
s.tools[t.Schema().Name] = t
|
||||
}
|
||||
|
||||
// RegisterAll 批量注册。
|
||||
func (s *Server) RegisterAll(ts ...Tool) {
|
||||
for _, t := range ts {
|
||||
s.Register(t)
|
||||
}
|
||||
}
|
||||
|
||||
// ToolCount 供测试与 /mcp 自述用。
|
||||
func (s *Server) ToolCount() int { return len(s.tools) }
|
||||
|
||||
// HandleHTTP 处理一次 POST。
|
||||
//
|
||||
// 认证在**外层**(main.go 把 middleware.AgentAuth 挂在这条路由上)——
|
||||
// 与普通 Agent 端点同一套凭证(Bearer 密钥或 name/secret),
|
||||
// 所以 MCP 不能成为绕过既有鉴权与收窄的后门。
|
||||
func (s *Server) HandleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
writeJSON(w, http.StatusMethodNotAllowed, failure(nil, rpcInvalidRequest, "MCP 端点只接受 POST"))
|
||||
return
|
||||
}
|
||||
// 限制请求体:工具调用都是小 JSON,附件走独立的 /attachments 端点。
|
||||
// 1MB 足够,且挡住"把整个文件塞进 JSON"的用法。
|
||||
const maxBody = 1 << 20
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, failure(nil, rpcParse, "读请求体失败:%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var msg rpcMessage
|
||||
if err := json.Unmarshal(body, &msg); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, failure(nil, rpcParse, "不是合法的 JSON"))
|
||||
return
|
||||
}
|
||||
if msg.JSONRPC != "2.0" {
|
||||
writeJSON(w, http.StatusBadRequest, failure(rawID(msg.ID), rpcInvalidRequest, `jsonrpc 字段必须是 "2.0"`))
|
||||
return
|
||||
}
|
||||
|
||||
out := s.handleMessage(&msg, r)
|
||||
if out == nil {
|
||||
// 通知(无 id):没有响应体。按规范回 202。
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleMessage 分发一条消息,返回要写回的响应;通知返回 nil。
|
||||
//
|
||||
// 抽出来是为了能**不经过 HTTP** 单测(httptest 之外也能穷举协议分支)。
|
||||
func (s *Server) handleMessage(msg *rpcMessage, r *http.Request) *rpcMessage {
|
||||
// 通知没有 id。回了响应,客户端会把响应与请求错配,后续调用全乱。
|
||||
isNotification := msg.ID == nil
|
||||
id := rawID(msg.ID)
|
||||
|
||||
switch msg.Method {
|
||||
case "initialize":
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
var p struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
_ = json.Unmarshal(msg.Params, &p)
|
||||
// 回显客户端给的版本:不认识的也回显,交由客户端决定是否降级。
|
||||
// 自作主张改成我们的版本会让客户端以为协商成功而按新语义调用。
|
||||
version := p.ProtocolVersion
|
||||
if version == "" {
|
||||
version = FallbackVersion
|
||||
}
|
||||
return result(id, map[string]any{
|
||||
"protocolVersion": version,
|
||||
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
|
||||
"serverInfo": map[string]any{"name": ServerName, "version": ServerVersion},
|
||||
})
|
||||
|
||||
case "notifications/initialized", "initialized":
|
||||
return nil // 纯通知
|
||||
|
||||
case "ping":
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
return result(id, map[string]any{})
|
||||
|
||||
case "tools/list":
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
list := make([]ToolSchema, 0, len(s.tools))
|
||||
for _, t := range s.tools {
|
||||
list = append(list, t.Schema())
|
||||
}
|
||||
// 稳定顺序:map 迭代随机会让客户端每次刷新看到不同排列。
|
||||
sortTools(list)
|
||||
return result(id, map[string]any{"tools": list})
|
||||
|
||||
case "tools/call":
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
var p toolCallParams
|
||||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||||
return failure(id, rpcInvalidParams, "tools/call 参数不是合法 JSON:%v", err)
|
||||
}
|
||||
if p.Name == "" {
|
||||
return failure(id, rpcInvalidParams, "tools/call 缺少 name")
|
||||
}
|
||||
t, ok := s.tools[p.Name]
|
||||
if !ok {
|
||||
// 未知工具名:回 INVALID_PARAMS 而不是「执行失败」——
|
||||
// 前者说"你叫错了",后者说"我试了但失败",模型的反应不同。
|
||||
return failure(id, rpcInvalidParams, "没有名为 %s 的工具(可用:%s)", p.Name, strings.Join(s.toolNames(), ", "))
|
||||
}
|
||||
args := p.Arguments
|
||||
if args == nil {
|
||||
args = map[string]any{} // 缺 arguments 当空对象,不抛错
|
||||
}
|
||||
text, err := t.Run(r.Context(), args)
|
||||
if err != nil {
|
||||
s.log.Printf("[mcp] 工具 %s 失败:%v", p.Name, err)
|
||||
// 失败走 result + isError,**不是** JSON-RPC error(见 toolResult 注释)。
|
||||
return result(id, errorResult("工具 %s 执行失败:%s", p.Name, err.Error()))
|
||||
}
|
||||
return result(id, textResult(text))
|
||||
|
||||
default:
|
||||
if isNotification {
|
||||
return nil
|
||||
}
|
||||
return failure(id, rpcMethodNotFound, "不支持的方法 %q", msg.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// toolNames 返回已注册工具名(错误文案里提示模型可用集合)。
|
||||
func (s *Server) toolNames() []string {
|
||||
names := make([]string, 0, len(s.tools))
|
||||
for n := range s.tools {
|
||||
names = append(names, n)
|
||||
}
|
||||
sortStrings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func sortTools(list []ToolSchema) {
|
||||
for i := 1; i < len(list); i++ {
|
||||
for j := i; j > 0 && list[j].Name < list[j-1].Name; j-- {
|
||||
list[j], list[j-1] = list[j-1], list[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
372
server/internal/mcp/support.go
Normal file
372
server/internal/mcp/support.go
Normal file
@ -0,0 +1,372 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/handler"
|
||||
)
|
||||
|
||||
// httptestRecorder 造一个响应收集器。
|
||||
func httptestRecorder() *httptest.ResponseRecorder { return httptest.NewRecorder() }
|
||||
|
||||
// jsonUnmarshal 是 json.Unmarshal 的薄封装(工具文件里用起来更短)。
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// newMultipartRequest 造一个 multipart/form-data 请求。
|
||||
func newMultipartRequest(ctx context.Context, body []byte, contentType string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/attachments", bytes.NewReader(body))
|
||||
r.Header.Set("Content-Type", contentType)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// buildUploadForm 组装上传用的 multipart 表单。
|
||||
//
|
||||
// 只**读**文件(打开 + 拷贝),不写。它读的是调用方给出的路径 ——
|
||||
// 见 tool_impls.go 里 upload_attachment 的注释:这是一处「工具参数即
|
||||
// 文件系统访问」,只读,且只对本服务自己跑在本机这件事成立(远程
|
||||
// Streamable HTTP 部署时读的是**网关机器**的路径,不是客户端的)。
|
||||
func buildUploadForm(path, filename string) ([]byte, string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, "", fmt.Errorf("缺少 file_path")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("打不开 %s:%v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if filename == "" {
|
||||
filename = filepath.Base(path)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
// 服务端字段名是 "file"(FormFile("file"))—— 写错名字会得到
|
||||
// "表单里没有 file 字段" 这种看不懂的错。
|
||||
part, err := mw.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := io.Copy(part, f); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return buf.Bytes(), mw.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
// summarize 把 handler 的成功响应转成模型读的文本。
|
||||
//
|
||||
// 为什么不直接把 JSON 甩给模型:
|
||||
// - read_inbox 一次可能返回几十封,每封都带邮件正文缩略 —— 纯 JSON 里
|
||||
// 转义与字段名会占掉大量 token,而且模型容易看错行(哪个是 subject、
|
||||
// 哪个是 from)。
|
||||
// - 人类与模型都需要**能扫读**的形状。
|
||||
//
|
||||
// 所以这里按已知的响应形状做小渲染;认不出来的形状回退到原始 JSON
|
||||
// (宁可费 token,也不要把信息丢了 —— 那会让模型以为邮件是空的)。
|
||||
func summarize(name string, payload map[string]any) (string, error) {
|
||||
switch name {
|
||||
case "read_inbox":
|
||||
return summarizeInbox(payload), nil
|
||||
case "read_mail":
|
||||
return summarizeMail(payload), nil
|
||||
case "read_thread":
|
||||
return summarizeThread(payload), nil
|
||||
case "send_mail":
|
||||
return summarizeSend(payload), nil
|
||||
case "forward_mail":
|
||||
return summarizeSend(payload), nil
|
||||
case "upload_attachment":
|
||||
return summarizeUpload(payload), nil
|
||||
case "suggest_address":
|
||||
return summarizeSuggest(payload), nil
|
||||
case "list_contacts":
|
||||
return summarizeContacts(payload), nil
|
||||
case "session_participants":
|
||||
return summarizeParticipants(payload), nil
|
||||
}
|
||||
return prettyJSON(payload), nil
|
||||
}
|
||||
|
||||
func prettyJSON(payload map[string]any) string {
|
||||
b, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", payload)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func str(m map[string]any, k string) string {
|
||||
if v, ok := m[k].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// summarizeInbox 渲染收件箱:每封一行,带 id(下一步要用)与是否有附件。
|
||||
func summarizeInbox(payload map[string]any) string {
|
||||
mails, _ := payload["mails"].([]any)
|
||||
total := numberOf(payload["total"])
|
||||
if len(mails) == 0 {
|
||||
if total > 0 {
|
||||
return fmt.Sprintf("(本会话/工作区内没有符合条件的未读,但 total=%d)", total)
|
||||
}
|
||||
return "收件箱为空。"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d 封未读(total=%d):\n\n", len(mails), total)
|
||||
for _, it := range mails {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := firstNonEmpty(str(m, "from_name"), str(m, "from"), "(未知)")
|
||||
subj := firstNonEmpty(str(m, "subject"), "(无主题)")
|
||||
id := str(m, "mail_id")
|
||||
flag := ""
|
||||
if n, _ := m["attachments"].([]any); len(n) > 0 {
|
||||
flag = fmt.Sprintf(" [附件 %d]", len(n))
|
||||
}
|
||||
if ws := str(m, "session_workspace"); ws != "" {
|
||||
flag += " " + ws
|
||||
}
|
||||
fmt.Fprintf(&b, "· %s — %s (id: %s)%s\n", from, subj, id, flag)
|
||||
}
|
||||
b.WriteString("\n用 read_mail(mail_id=…) 读正文。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// summarizeMail 渲染单封:头部 + 正文 + 附件清单。
|
||||
func summarizeMail(payload map[string]any) string {
|
||||
m := payload
|
||||
if inner, ok := payload["mail"].(map[string]any); ok {
|
||||
m = inner
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "发件人:%s\n主题:%s\n邮件 ID:%s\n日期:%s\n",
|
||||
firstNonEmpty(str(m, "from_name"), "(未知)"),
|
||||
firstNonEmpty(str(m, "subject"), "(无主题)"),
|
||||
str(m, "mail_id"),
|
||||
firstNonEmpty(str(m, "created_at"), str(m, "sent_at")))
|
||||
if parent := str(m, "parent_mail_id"); parent != "" {
|
||||
fmt.Fprintf(&b, "(这是对 %s 的回复)\n", parent)
|
||||
}
|
||||
b.WriteString("\n---\n")
|
||||
b.WriteString(str(m, "body"))
|
||||
if atts, ok := m["attachments"].([]any); ok && len(atts) > 0 {
|
||||
b.WriteString("\n\n附件:")
|
||||
for _, a := range atts {
|
||||
am, _ := a.(map[string]any)
|
||||
fmt.Fprintf(&b, "\n· %s (attachment_id: %s, %s)",
|
||||
firstNonEmpty(str(am, "filename"), "(未命名)"),
|
||||
str(am, "attachment_id"),
|
||||
humanSize(numberOf(am["size_bytes"])))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// summarizeThread 渲染线索:按时间顺序每封一段。
|
||||
func summarizeThread(payload map[string]any) string {
|
||||
mails, _ := payload["mails"].([]any)
|
||||
if mails == nil {
|
||||
mails, _ = payload["thread"].([]any)
|
||||
}
|
||||
if len(mails) == 0 {
|
||||
return "线索里没有邮件。"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "线索共 %d 封:\n\n", len(mails))
|
||||
for _, it := range mails {
|
||||
m, _ := it.(map[string]any)
|
||||
fmt.Fprintf(&b, "── %s · %s(id: %s)\n%s\n\n",
|
||||
firstNonEmpty(str(m, "from_name"), "(未知)"),
|
||||
firstNonEmpty(str(m, "subject"), "(无主题)"),
|
||||
str(m, "mail_id"),
|
||||
strings.TrimSpace(str(m, "body")))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summarizeSend(payload map[string]any) string {
|
||||
id := str(payload, "mail_id")
|
||||
sid := str(payload, "session_id")
|
||||
var b strings.Builder
|
||||
b.WriteString("已发送")
|
||||
if id != "" {
|
||||
fmt.Fprintf(&b, "(mail_id: %s)", id)
|
||||
}
|
||||
if sid != "" {
|
||||
fmt.Fprintf(&b, ",落在会话 %s", sid)
|
||||
}
|
||||
if relay := str(payload, "duplicate_relay"); relay != "" {
|
||||
fmt.Fprintf(&b, "。注意:%s", relay)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summarizeUpload(payload map[string]any) string {
|
||||
// UploadAttachment 返回的是 {attachment:{...}} 这种**嵌套**形状
|
||||
// —— 按顶层解会得到空 id(homeagent 与 zcode 都踩过)。
|
||||
att, _ := payload["attachment"].(map[string]any)
|
||||
if att == nil {
|
||||
att = payload
|
||||
}
|
||||
return fmt.Sprintf("已上传 %s,attachment_id: %s(%s)。在 send_mail 的 attachment_ids 里带上它才会随邮件发出。",
|
||||
firstNonEmpty(str(att, "filename"), "(未命名)"),
|
||||
str(att, "attachment_id"),
|
||||
humanSize(numberOf(att["size_bytes"])))
|
||||
}
|
||||
|
||||
func summarizeSuggest(payload map[string]any) string {
|
||||
kind := str(payload, "kind")
|
||||
suggestions, _ := payload["suggestions"].([]any)
|
||||
if len(suggestions) == 0 {
|
||||
return fmt.Sprintf("(没有%s建议)", kind)
|
||||
}
|
||||
items := make([]string, 0, len(suggestions))
|
||||
for _, s := range suggestions {
|
||||
if str, ok := s.(string); ok {
|
||||
items = append(items, str)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Sprintf("(没有%s建议)", kind)
|
||||
}
|
||||
return fmt.Sprintf("可选的%s:\n%s", kind, strings.Join(items, "\n"))
|
||||
}
|
||||
|
||||
func summarizeContacts(payload map[string]any) string {
|
||||
contacts, _ := payload["contacts"].([]any)
|
||||
if len(contacts) == 0 {
|
||||
return "没有参与过任何会话。用 suggest_address 查可投递地址。"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "共 %d 条会话:\n\n", len(contacts))
|
||||
for _, it := range contacts {
|
||||
c, _ := it.(map[string]any)
|
||||
unread := numberOf(c["unread_count"])
|
||||
flag := ""
|
||||
if unread > 0 {
|
||||
flag = fmt.Sprintf(" ★ %d 未读", unread)
|
||||
}
|
||||
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n session_id: %s\n",
|
||||
firstNonEmpty(str(c, "peer"), "(未知)"),
|
||||
firstNonEmpty(str(c, "subject"), "(无主题)"),
|
||||
flag,
|
||||
firstNonEmpty(str(c, "address"), "(无可寻址地址)"),
|
||||
str(c, "session_id"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summarizeParticipants(payload map[string]any) string {
|
||||
parts, _ := payload["participants"].([]any)
|
||||
if len(parts) == 0 {
|
||||
return "会话里没有其他参与方。"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("参与方:\n")
|
||||
for _, it := range parts {
|
||||
p, _ := it.(map[string]any)
|
||||
flag := ""
|
||||
if !boolOf(p["replied"]) {
|
||||
flag = "(还没回应)"
|
||||
}
|
||||
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n",
|
||||
firstNonEmpty(str(p, "role"), "?"),
|
||||
firstNonEmpty(str(p, "name"), "(未知)"),
|
||||
flag,
|
||||
firstNonEmpty(str(p, "address"), "(无)"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---- 小工具 ----
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func numberOf(v any) int {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return int(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func boolOf(v any) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func humanSize(n int) string {
|
||||
switch {
|
||||
case n <= 0:
|
||||
return "0 B"
|
||||
case n < 1024:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
case n < 1024*1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
||||
}
|
||||
}
|
||||
|
||||
// agentNameOfRequest 供 handler 包在需要时取身份。
|
||||
//
|
||||
// 单独一个 invoke:DownloadAttachment 的响应体是**附件字节**而不是 JSON,
|
||||
// 走通用的 invoke 会把二进制当 JSON 解析(静默得到 nil),
|
||||
// 于是成功时也会报"解析不出 error"。所以这里自己收集。
|
||||
func invokeDownload(ctx context.Context, target, id, savePath string) (int, map[string]any, string) {
|
||||
// 路径参数 id 必须注入(DownloadAttachment 用 pathUUID → chi.URLParam)。
|
||||
// 注入后**不要再** WithContext(ctx) —— 那会覆盖掉 RouteContext。
|
||||
r := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.DownloadAttachment(rec, r)
|
||||
body := rec.Body.Bytes()
|
||||
if rec.Code < 200 || rec.Code >= 300 {
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(body, &out)
|
||||
return rec.Code, out, string(body)
|
||||
}
|
||||
// 落盘(建父目录)。写失败要**报错**而不是假装成功 ——
|
||||
// 模型会以为文件在那儿,然后 upload_attachment 打不开它。
|
||||
if dir := filepath.Dir(savePath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return http.StatusInternalServerError,
|
||||
map[string]any{"error": "建目录失败:" + err.Error()}, ""
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(savePath, body, 0o644); err != nil {
|
||||
return http.StatusInternalServerError,
|
||||
map[string]any{"error": "写文件失败:" + err.Error()}, ""
|
||||
}
|
||||
return http.StatusOK, map[string]any{
|
||||
"saved_path": savePath,
|
||||
"size_bytes": len(body),
|
||||
"description": fmt.Sprintf("已保存 %s(%s)", savePath, humanSize(len(body))),
|
||||
}, ""
|
||||
}
|
||||
364
server/internal/mcp/tool_impls.go
Normal file
364
server/internal/mcp/tool_impls.go
Normal file
@ -0,0 +1,364 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
266
server/internal/mcp/tools.go
Normal file
266
server/internal/mcp/tools.go
Normal file
@ -0,0 +1,266 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Tools 构造网关的 11 个 MCP 工具。
|
||||
//
|
||||
// # 为什么是**包装 handler**而不是直调 repo
|
||||
//
|
||||
// 早先那版 MCP 在独立进程里用 HTTP 调本网关,于是工具语义是**手抄**的一份:
|
||||
// 参数、收窄、配额、错误文案各写一遍。实测已抓到后果 ——
|
||||
// `connect_to_server` 只发 `X-Agent-Secret` 头,而 `/agent/register` 只认
|
||||
// Bearer 或 body 里的 `secret`,于是 secret-only 的 Agent 调它必然 400,
|
||||
// 而模型看到 401/400 拼不出该改什么。
|
||||
//
|
||||
// 包装 handler 之后:**只有一份语义**。工作区收窄(`GetInbox` 缺 workspace
|
||||
// 直接 400)、会话收窄(`agentScope`)、Agent↔Agent 冷静期、配额、
|
||||
// 附件保护目录 —— 全部是同一条代码路径,不是复述。
|
||||
//
|
||||
// # 身份从哪来
|
||||
//
|
||||
// 不从工具参数里取身份。`AgentAuth` 已把身份放进 request context
|
||||
// (`middleware.AgentNameKey`),每个内部请求都带上它。所以工具**无法**
|
||||
// 通过参数冒充别的 Agent —— 那正是 2026-10-02 修掉的越权形状
|
||||
// (`AgentMayReadSession` 的 `if scope == nil { return true }`)。
|
||||
type Tools struct{}
|
||||
|
||||
// NewTools 造工具集。
|
||||
func NewTools() *Tools { return &Tools{} }
|
||||
|
||||
// RegisterAll 把 11 个工具注册进端点。
|
||||
//
|
||||
// 工具名与参数名与 pi / dsh / opencode / zcode 四桥**逐字一致** —— 同一件事
|
||||
// 在任何平台上必须是同一种做法,否则会出现「只在这个平台上模型不会回信」
|
||||
// 这类单平台复现、排查代价最高的问题。
|
||||
func (t *Tools) RegisterAll(s *Server) {
|
||||
s.RegisterAll(
|
||||
t.ReadInbox(), t.ReadMail(), t.ReadThread(), t.SendMail(),
|
||||
t.ForwardMail(), t.UploadAttachment(), t.DownloadAttachment(),
|
||||
t.SuggestAddress(), t.ListContacts(), t.SessionParticipants(),
|
||||
t.ConnectToServer(),
|
||||
)
|
||||
}
|
||||
|
||||
// invoke 调一个 handler,拿到状态码与解码后的 JSON。
|
||||
//
|
||||
// 这是全部工具共用的骨架:造请求 → 交给 handler → 解读结果。
|
||||
// handler 用 httptest.ResponseRecorder 收集(它是标准库类型,零依赖),
|
||||
// 因为我们要的是"handler 的语义",不是真的走网络。
|
||||
func invoke(ctx context.Context, h http.HandlerFunc, req *http.Request) (int, map[string]any, string) {
|
||||
// ★ 不要在这里 `req = req.WithContext(ctx)`。
|
||||
//
|
||||
// req 身上已经挂了两样东西,而用 ctx 重建会**丢掉后面挂的那一样**:
|
||||
// - 身份(middleware.AgentNameKey):由 newRequest 挂上
|
||||
// - 路径参数(chi.RouteCtxKey):由 withRouteParams 挂上
|
||||
//
|
||||
// withRouteParams 之后调 WithContext(ctx),chi 的 RouteContext 就没了 ⇒
|
||||
// handler 里 chi.URLParam 恒为空 ⇒ 报「Invalid id」。
|
||||
// ctx 是**外层请求**的 context,而 req 已经是它的派生物(newRequest 里
|
||||
// 做过一次 WithContext);所以这里什么都不做才是对的。
|
||||
// (判据:TestPathParamsReachHandlers)
|
||||
_ = ctx
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
var out map[string]any
|
||||
raw := rec.Body.Bytes()
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return rec.Code, out, string(raw)
|
||||
}
|
||||
|
||||
// render 把 handler 的响应转成模型看的文本。
|
||||
//
|
||||
// 规则:成功时给可读摘要(而**不是**整坨 JSON —— 模型读 50 封邮件的 JSON
|
||||
// 既费 token 又容易看错行);失败时**原样带上 handler 的错误文案**,
|
||||
// 那是让人/模型能改道的信息,不能吞掉。
|
||||
func render(name string, status int, payload map[string]any, raw string) (string, error) {
|
||||
if status >= 200 && status < 300 {
|
||||
return summarize(name, payload)
|
||||
}
|
||||
msg := errorMessage(payload)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(raw)
|
||||
}
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", status)
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
func errorMessage(payload map[string]any) string {
|
||||
if payload == nil {
|
||||
return ""
|
||||
}
|
||||
for _, k := range []string{"error", "message", "suggestion"} {
|
||||
if v, ok := payload[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- 参数取值 ----
|
||||
//
|
||||
// 工具参数一律宽容:数字既能是 JSON number 也能是字符串(模型两种都发过)。
|
||||
// 严格解码会让一个本来能用的调用失败,而失败信息("类型不匹配")对模型没用。
|
||||
|
||||
func argStr(args map[string]any, key string) string {
|
||||
if v, ok := args[key]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
// 纯整数的 number 当字符串用(id 常见地被模型写成数字)
|
||||
if t == float64(int64(t)) {
|
||||
return fmt.Sprintf("%d", int64(t))
|
||||
}
|
||||
return fmt.Sprintf("%v", t)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", t)
|
||||
case nil:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func argInt(args map[string]any, key string, def int) int {
|
||||
s := argStr(args, key)
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(s, "%d", &n); err == nil {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// argBool 取布尔。字符串 "true"/"false" 也认。
|
||||
func argBool(args map[string]any, key string) bool {
|
||||
switch v := args[key].(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// argStrList 取字符串数组(逗号分隔的字符串也认)。
|
||||
func argStrList(args map[string]any, key string) []string {
|
||||
switch v := args[key].(type) {
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s := argStr(map[string]any{"v": item}, "v"); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
parts := strings.FieldsFunc(v, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == ' ' || r == '\n'
|
||||
})
|
||||
return parts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withRouteParams 把路径参数注入 request context。
|
||||
//
|
||||
// ★ 为什么必须这么做:被包装的 handler 用 `chi.URLParam(r, "id")` 取路径参数
|
||||
// (`pathUUID` → `uuid.Parse(chi.URLParam(r, "id"))`)。而我们用
|
||||
// httptest.NewRequest 造的请求**没有经过 chi 的路由**,URLParam 恒为空串
|
||||
// ⇒ 任何带路径参数的工具(read_mail / read_thread / forward_mail /
|
||||
// download_attachment / session_participants)都会报「Invalid id」。
|
||||
//
|
||||
// 这个 bug 是**端到端**撞出来的:主人读自己的信与越权者读别人的信**都**返回
|
||||
// 「Invalid id」。对照实验立刻说明这不是权限问题而是参数没到位 —— 如果只看
|
||||
// 越权那一次,会误判成「收得太紧」,进而把正确的收窄改松。
|
||||
//
|
||||
// 用 chi 自己的 RouteContext 注入,而不是自造 context key:handler 读的是
|
||||
// chi.URLParam,两边必须说同一种话。
|
||||
func withRouteParams(r *http.Request, params map[string]string) *http.Request {
|
||||
if len(params) == 0 {
|
||||
return r
|
||||
}
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range params {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// objSchema 造一个 object 类型的入参 schema。
|
||||
func objSchema(props map[string]any, required ...string) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func strProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": desc}
|
||||
}
|
||||
func numProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "integer", "description": desc}
|
||||
}
|
||||
func boolProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "boolean", "description": desc}
|
||||
}
|
||||
|
||||
func arrayProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
|
||||
}
|
||||
|
||||
// 只读标注。宿主据此算风险等级并在 plan 档放行(漏传会被全拒)。
|
||||
var readOnly = map[string]any{"readOnlyHint": true, "destructiveHint": false}
|
||||
|
||||
// 写但不破坏性(发信、转发:会改变别人的收件箱,但不删数据)。
|
||||
var writeSafe = map[string]any{"readOnlyHint": false, "destructiveHint": false}
|
||||
|
||||
// hasWorkspaceArg 在没有 workspace 时给出**带例子**的错误。
|
||||
//
|
||||
// 直接复用 GetInbox 的错误文案即可(包装 handler 的好处):
|
||||
// 那是唯一的口径,模型见一次就记住。
|
||||
func newRequest(ctx context.Context, method, target string, body any) *http.Request {
|
||||
var r *http.Request
|
||||
if body != nil {
|
||||
buf, _ := json.Marshal(body)
|
||||
r = httptest.NewRequest(method, target, bytes.NewReader(buf))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
r = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
// 身份从调用链的 context 继承(AgentAuth 放进去的)。
|
||||
r = r.WithContext(ctx)
|
||||
return r
|
||||
}
|
||||
|
||||
// withQuery 在 URL 上加查询参数。
|
||||
func withQuery(target string, kv map[string]string) string {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return target
|
||||
}
|
||||
q := u.Query()
|
||||
for k, v := range kv {
|
||||
if v != "" {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
Reference in New Issue
Block a user