feat(mcp): GET /api/v1/mcp —— 投递侧事件流(让接入方被动收信,不用轮询)

## 这半边解决什么

工具面(POST)只解决「接入方**问**」。这一条解决「服务端**说**」:
邮件投递时把 new_mail / session_update 推给接入方,让它**拉起对话** ——
与各桥靠 /api/v1/events/stream 收信是同一件事,只是方言不同:

    桥:   id: 7\nevent: new_mail\ndata: {…}\n\n
    MCP:  {"jsonrpc":"2.0","method":"notifications/message","params":{…}}

## 为什么复用 sse.Manager 而不是另起一套

Manager 里那些东西**都是踩过坑才对的**:writeMu 串行化(2026-09-28 -race
实测 http.ResponseWriter 并发写会把 JSON 劈成半截,800 帧只切出 459 个完整)、
Last-Event-ID 回放(宁可重复也不丢失)、心跳(反代按空闲 30-58s 掐连接)、
环形缓冲上限、断线清理。复制一份等于把那些坑再踩一遍,
而两边的修复从此各走各的。

代价是 `sse.Client` 多了一个可选 `Frame` 钩子:
**nil = AgentMail 原格式,各桥与 WebUI 行为一字未变**(默认值即历史行为)。

## ★ 回放是第三条写路径,漏了就只在断线时现形

`Send` / `SendWithID` / `replay` 是三条写 Res 的路径。原先**三条都把格式写死**,
只改前两条的话:MCP 客户端**平时**一切正常,只有带 `Last-Event-ID` 重连时
才会收到一批自己解不开的帧 —— 同一个连接上两种方言。

判据 `TestCustomFrameAppliesToReplayToo` 专门钉这条,并带反向对照
(nil 帧必须回落 AgentMail 格式)。

`Frame` 必须在**注册时**传入(`AddClientWithFrame`),不能事后设 ——
回放发生在「先写响应、再注册」的前半段,事后设只影响之后推来的事件。
原先 `AddClient` 保留为薄封装,各桥与 WebUI 调用点一字未改。

## 判据(6 格)

    Frame 是 JSON-RPC 2.0 通知 + 帧完整性(单事件、\n\n 结尾)
    payload 原样嵌入(不是 JSON 字符串)—— 再 marshal 会让客户端解析两次
    event_id / event_type 必带(前者是 Last-Event-ID 续传的依据)
    Accept 判定(含 q 值、大小写)
    匿名 GET → 401(不能变成静默的匿名订阅)
    缺 Accept → 406(接错的客户端会静默收不到东西)

## 顺带修:TestAdvanceRecurrenceLunar 的时区缺陷(★ 今天第三次假红)

全量测试红了,查下来是**我今天早些时候改判据时引入的**,与本次改动无关。

农历换算必须按**本地公历日**算(`AdvanceRecurrence` 里那句
`eventTime.In(time.Local)` 就是这条规则)。库里读回的 EventTime 是 **UTC**
(DSN 用 `_timezone=UTC`),UTC 比本地晚 8 小时,跨零点时农历日差一天:

    start    (Local) = 2026-10-04        农历日 24
    after    (UTC)   = 2026-11-01 16:00   农历日 23   ← 断言没换算时区(错)
    after.In(Local)  = 2026-11-02 00:00   农历日 24   ← 正确

服务端代码一直是对的,是判据没照做。失败信息里现在打印时区,
免得下次要重新推导一遍。变异验证:去掉 `.In(time.Local)` → 红 1 ✓

(这条判据是农历的第三次假红了:3459605「断言要求不存在的农历日」、
今天早些「起点写死日期 + advanceToFuture 跳过过期月份」、现在「没换算时区」——
三次都是判据自己写错,代码三次都对。它依赖 Local 时区与「今天」,
天生脆弱,值得记着。)

## 验证

    go test ./...              14 包全绿
    go test ./internal/sse/    含新判据绿
    go test ./internal/mcp/    6 格新判据 + 原 19 格全绿
This commit is contained in:
2026-10-02 15:37:34 +08:00
parent 5e312c6f5f
commit 560c462768
7 changed files with 357 additions and 13 deletions

View File

@ -16,9 +16,14 @@ package mcp
*/
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/agentmail/gateway/internal/middleware"
)
// 服务端的正则,在这里 import 而不是复制 —— 复制一份就等于放弃了「镜像」的意义。
@ -177,3 +182,103 @@ func TestToolsExposeParamsOtherBridgesHave(t *testing.T) {
}
var _ = fmt.Sprintf
// ─── GET /mcp 事件流(投递/拉起对话)────────────────────────
/*
★ 方言必须是 JSON-RPC 通知。
这一格存在的理由:Frame 决定了接入方能不能解析。写错了不会报错 ——
连接是活的、字节在流动,只是客户端**解不开**而默默丢弃每一个事件。
那种失败看起来完全像「没有人给我发信」。
*/
func TestFrameRendersJSONRPCNotification(t *testing.T) {
out := Frame("7", "new_mail", []byte(`{"mail_id":"m1","subject":"你好"}`))
if !strings.HasPrefix(out, `{"jsonrpc":"2.0","method":"notifications/message"`) {
t.Fatalf("必须是 JSON-RPC 2.0 通知:%q", out)
}
if !strings.HasSuffix(out, "\n\n") {
t.Errorf("SSE 帧必须以空行结尾(否则客户端不认为事件结束):%q", out)
}
// payload 必须**原样嵌入**而不是被转义成字符串 ——
// 否则客户端要解析两次,长度还翻倍。
if !strings.Contains(out, `"payload":{"mail_id":"m1","subject":"你好"}`) {
t.Errorf("payload 应原样嵌入(不是 JSON 字符串):%q", out)
}
// 反向对照:转义版本(错误实现)不该出现
if strings.Contains(out, `"payload":"{`) {
t.Errorf("★ payload 被转义成字符串了:%q", out)
}
}
func TestFrameCarriesEventTypeAndID(t *testing.T) {
out := Frame("42", "session_update", []byte(`{}`))
if !strings.Contains(out, `"event_id":"42"`) {
t.Errorf("必须带 event_id(客户端据此做 Last-Event-ID 续传):%q", out)
}
if !strings.Contains(out, `"event_type":"session_update"`) {
t.Errorf("必须带 event_type(否则客户端分不清新邮件与会话更新):%q", out)
}
}
// 单行 + 空行分隔:SSE 的帧完整性靠它,多一个换行会把一条拆成两条。
func TestFrameIsSingleEvent(t *testing.T) {
out := Frame("1", "new_mail", []byte(`{}`))
if strings.Count(out, "\n\n") != 1 {
t.Errorf("一个事件只能有一个空行分隔:%q", out)
}
if strings.Contains(strings.TrimSuffix(out, "\n\n"), "\n\n") {
t.Errorf("事件体内不得出现空行:%q", out)
}
}
// Accept 判定:必须真的要求 text/event-stream。
func TestAcceptsEventStream(t *testing.T) {
cases := []struct {
accept string
want bool
}{
{"text/event-stream", true},
{"text/event-stream, application/json", true},
{"text/event-stream;q=0.9", true},
{"TEXT/EVENT-STREAM", true},
{"application/json", false},
{"", false},
{"text/plain", false},
}
for _, c := range cases {
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
if c.accept != "" {
r.Header.Set("Accept", c.accept)
}
if got := acceptsEventStream(r); got != c.want {
t.Errorf("Accept=%q 期望 %v 收到 %v", c.accept, c.want, got)
}
}
}
// 未认证的 GET 必须拒绝(不能变成一处静默的匿名订阅)。
func TestGetRequiresAgentIdentity(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
r.Header.Set("Accept", "text/event-stream")
// 故意不注入身份
s.HandleGET(rec, r)
if rec.Code != http.StatusUnauthorized {
t.Errorf("匿名订阅必须 401,收到 %d", rec.Code)
}
}
// 不带 Accept 的 GET 必须 406(接错的客户端会静默收不到东西)。
func TestGetRequiresEventStreamAccept(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
r = r.WithContext(context.WithValue(r.Context(), middleware.AgentNameKey, "probe"))
s.HandleGET(rec, r)
if rec.Code != http.StatusNotAcceptable {
t.Errorf("缺 Accept 应 406,收到 %d", rec.Code)
}
}