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

@ -52,13 +52,22 @@ func (r *eventRing) push(evt StoredEvent) {
// afterID 为空时:缓冲区未满不回放(首次连接无历史);满了也不回放
// (首次连接的 EventSource 不传 Last-Event-ID)。
// afterID 非空时:找到该 ID 的位置,从下一条开始回放。
func (r *eventRing) replay(afterID string, flush http.Flusher, res http.ResponseWriter) {
//
// ★ frame 必传(2026-10-02):回放是**第三条**写 Res 的路径,它原本也把格式
// 写死成 AgentMail 的 SSE 形状。MCP 连接断线重连时走的就是这条路 ——
// 不传 frame 会让它收到一堆自己的客户端解不开的帧(同一个连接上两种方言)。
// 这种错只在「MCP 客户端带 Last-Event-ID 重连」时现形,平时完全看不见。
func (r *eventRing) replay(afterID string, flush http.Flusher, res http.ResponseWriter,
frame func(id, eventType string, data []byte) string) {
r.mu.Lock()
defer r.mu.Unlock()
if afterID == "" {
return // 首次连接,不回放
}
if frame == nil {
frame = defaultFrame
}
start := -1
total := r.cap
@ -90,7 +99,7 @@ func (r *eventRing) replay(afterID string, flush http.Flusher, res http.Response
if evt.ID == "" {
continue
}
fmt.Fprintf(res, "id: %s\nevent: %s\ndata: %s\n\n", evt.ID, evt.EventType, evt.Data)
fmt.Fprint(res, frame(evt.ID, evt.EventType, evt.Data))
}
flush.Flush()
}
@ -127,6 +136,38 @@ type Client struct {
// SendToRecipient / Broadcast / replay),要保证"同一个 client 的所有写互斥",
// 责任只能落在 client 自己身上 —— 那是唯一能覆盖**全部**写者的位置。
writeMu sync.Mutex
// Frame 可选:把一条事件渲染成**要写进流里的字节**。
//
// 为什么需要它(2026-10-02,MCP 端点要把同一批事件说成别的协议):
// 本管理器原本把帧格式**写死**成 AgentMail 的 SSE 形状。而 MCP
// (Streamable HTTP)要求服务端→客户端的方向用 **JSON-RPC 通知**经 SSE 下发
// —— 同一根管子、两种方言。
//
// 把格式抽成一个函数而不是复制一份 Manager:心跳、Last-Event-ID 回放、
// 断线清理、`writeMu` 串行化、容量上限、事件环缓冲 —— 这些是**踩过坑才对的**
// (见 writeMu 的注释)。复制一份等于把那些坑再踩一遍,
// 而两边的修复会各走各的。
//
// nil = 用默认的 AgentMail 格式(各桥与 WebUI 走这条,行为一字未变)。
Frame func(id, eventType string, data []byte) string
}
// defaultFrame 是 AgentMail 自己的 SSE 帧格式(带 id)。
func defaultFrame(id, eventType string, data []byte) string {
return fmt.Sprintf("id: %s\nevent: %s\ndata: %s\n\n", id, eventType, data)
}
// fill 是**唯一**把事件写进 Res 的地方(心跳除外的两条推送路径共用)。
//
// 抽出来的意义:让「默认格式」与「自定义格式」在代码上对称 —— 将来改默认格式时,
// 两边的差异一眼可见;而漏掉其中一条路径就会让某种连接收到半截方言。
func (c *Client) fill(id, eventType string, data []byte) {
if c.Frame != nil {
fmt.Fprint(c.Res, c.Frame(id, eventType, data))
return
}
fmt.Fprint(c.Res, defaultFrame(id, eventType, data))
}
// Manager 管理所有 SSE 客户端连接
@ -182,7 +223,24 @@ func (m *Manager) getOrCreateRing(key string) *eventRing {
}
// AddClient 注册一个新 SSE 客户端(agentName 与 userName 二者恰其一)
// AddClient 注册一个 SSE 客户端,用**默认** AgentMail 帧格式。
//
// 各桥与 WebUI 走这条(行为与历史完全一致)。需要别的方言的调用方
// (MCP 端点把同一批事件说成 JSON-RPC 通知)用 AddClientWithFrame。
func (m *Manager) AddClient(res http.ResponseWriter, r *http.Request, agentName, userName string) *Client {
return m.AddClientWithFrame(res, r, agentName, userName, nil)
}
// AddClientWithFrame 注册一个 SSE 客户端,用调用方给的帧格式。
//
// ★ frame 必须在**构造时**传入,不能注册后再设。
//
// 原因:Last-Event-ID 回放发生在“先写响应、再注册”的**前半段**(见下面那段
// 关于持 writeMu 的注释)。事后挂 Frame 只能影响之后推来的事件,
// 而重连回放那一批仍会走默认格式 —— 同一个连接上出现两种方言,
// 且**只在真实断线重连时现形**。frame=nil 表示默认格式。
func (m *Manager) AddClientWithFrame(res http.ResponseWriter, r *http.Request,
agentName, userName string, frame func(id, eventType string, data []byte) string) *Client {
flusher, ok := res.(http.Flusher)
if !ok {
return nil
@ -196,6 +254,7 @@ func (m *Manager) AddClient(res http.ResponseWriter, r *http.Request, agentName,
Res: res,
Flusher: flusher,
done: make(chan struct{}),
Frame: frame,
}
// 设置 SSE 响应头
@ -231,7 +290,7 @@ func (m *Manager) AddClient(res http.ResponseWriter, r *http.Request, agentName,
key := m.bufferKey(userName, agentName)
if ring := m.getOrCreateRing(key); ring != nil && lastID != "" {
client.writeMu.Lock()
ring.replay(lastID, flusher, res)
ring.replay(lastID, flusher, res, client.Frame)
client.writeMu.Unlock()
}
@ -383,7 +442,11 @@ func (c *Client) Send(eventType string, data interface{}) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
if c.Frame != nil {
fmt.Fprint(c.Res, c.Frame("", eventType, jsonData))
} else {
fmt.Fprintf(c.Res, "event: %s\ndata: %s\n\n", eventType, jsonData)
}
c.Flusher.Flush()
}
@ -398,7 +461,7 @@ func (c *Client) SendWithID(id, eventType string, data interface{}) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
fmt.Fprintf(c.Res, "id: %s\nevent: %s\ndata: %s\n\n", id, eventType, jsonData)
c.fill(id, eventType, jsonData)
c.Flusher.Flush()
}

View File

@ -2,6 +2,7 @@ package sse
import (
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@ -22,14 +23,14 @@ func TestEventRingPushReplay(t *testing.T) {
// 空 afterID → 首次连接,不回放(缓冲区未满)
rec := httptest.NewRecorder()
ring.replay("", rec, rec)
ring.replay("", rec, rec, nil)
if rec.Body.Len() > 0 {
t.Error("首次连接不应回放事件,实际:", rec.Body.String())
}
// 有 afterID → 从下一条开始回放
rec2 := httptest.NewRecorder()
ring.replay("1", rec2, rec2)
ring.replay("1", rec2, rec2, nil)
body := rec2.Body.String()
if !strings.Contains(body, "id: 2") {
t.Error("afterID=1 应该回放 id:2,实际:", body)
@ -43,7 +44,7 @@ func TestEventRingPushReplay(t *testing.T) {
// 不存在的 afterID → 从头回放全部
rec3 := httptest.NewRecorder()
ring.replay("999", rec3, rec3)
ring.replay("999", rec3, rec3, nil)
body3 := rec3.Body.String()
if !strings.Contains(body3, "id: 1") {
t.Error("不存在的 afterID 应从头回放,实际:", body3)
@ -69,7 +70,7 @@ func TestEventRingOverflow(t *testing.T) {
// afterID=2 已被覆盖 → 找不到位置,从头回放全部
rec := httptest.NewRecorder()
ring.replay("2", rec, rec)
ring.replay("2", rec, rec, nil)
body := rec.Body.String()
if !strings.Contains(body, "id: 3") || !strings.Contains(body, "id: 5") {
t.Error("缓冲区溢出后应能回放可用范围,实际:", body)
@ -111,3 +112,52 @@ func TestHeartbeatIntervalIsWellUnderProxyIdleTimeout(t *testing.T) {
"心跳必须明显小于该超时(当前上限取 20s)", heartbeatInterval)
}
}
/*
★ 2026-10-02:Frame 钩子必须在**两条**写路径上都生效。
回放是第三条写 Res 的路径(另两条是 Send / SendWithID)。它原先也把格式写死成
AgentMail 的 SSE 形状 —— MCP 端点要靠它把同一批事件说成 JSON-RPC 方言。
这一格存在的理由:只改 SendWithID 而漏改 replay,**平时完全看不出来** ——
只有在「MCP 客户端带着 Last-Event-ID 重连」时才会收到一堆自己解不开的帧。
那种错要等真实断线才现形,所以只能靠判据钉。
*/
func TestCustomFrameAppliesToReplayToo(t *testing.T) {
ring := newEventRing(8)
for i := 1; i <= 3; i++ {
ring.push(StoredEvent{
ID: strconv.Itoa(i),
EventType: "new_mail",
Data: []byte(`{"n":1}`),
Timestamp: time.Now(),
})
}
// 自定义方言:一眼可辨,且**不带** AgentMail 的 `event:` 行
jsonrpcFrame := func(id, eventType string, data []byte) string {
return `{"jsonrpc":"2.0","method":"notifications/message","params":{` +
`"id":"` + id + `","type":"` + eventType + `","payload":` + string(data) + `}}` + "\n\n"
}
rec := httptest.NewRecorder()
ring.replay("1", rec, rec, jsonrpcFrame)
body := rec.Body.String()
if !strings.Contains(body, `"jsonrpc":"2.0"`) {
t.Errorf("★ 回放必须用自定义 frame(否则重连的 MCP 客户端收到异种方言):%q", body)
}
if strings.Contains(body, "event: new_mail") {
t.Errorf("★ 回放仍写成 AgentMail 的 SSE 形状 —— Frame 钩子漏接在 replay 路径上:%q", body)
}
if !strings.Contains(body, `"id":"2"`) {
t.Errorf("回放内容不对(应从 afterID=1 之后开始):%q", body)
}
// 反向对照:nil ⇒ 默认格式一字未变(各桥与 WebUI 走这条)
rec2 := httptest.NewRecorder()
ring.replay("1", rec2, rec2, nil)
if !strings.Contains(rec2.Body.String(), "event: new_mail") {
t.Errorf("nil frame 必须回落默认 AgentMail 格式:%q", rec2.Body.String())
}
}