用户 2026-10-04:「不只是 Agent 主动回,还有自动转发 Agent 的最后一条消息」。
## 为什么 MCP 侧需要显式上报,而插件侧不用
插件(pi / dsh / homeagent)有 `session.idle` 钩子:
宿主进程在自己会话 idle 时读到最后一条回复 → 自动 send_mail(relay=…)
MCP 宿主(Codex / Claude Desktop)**没有这个概念**:
不知道「这一轮处理完了」,也不会主动调 send_mail。
⇒ 宿主必须显式上报,服务端才可能自动转发。
所以新增 `send_mail(relay="summary", relay_key="<宿主消息 id>")`:
relay=summary 免配额(服务端既有逻辑),relay_key 提供幂等。
## 顺带修掉一个从未生效的幂等文案(既有缺陷)
MCP 侧读:str(payload, "duplicate_relay")
服务端返:{"status": "duplicate_relay", "detail": "该上游消息已转发过…"}
**字段名对不上 ⇒ 那段逻辑从来没生效过。**
后果对 MCP 接入端尤其致命:它的自动转发正是靠 relay_key 幂等兜底,
而宿主重试时看到的是「已发送」(无 mail_id、无任何异常),
于是继续重试 —— 每一轮都在消耗一次往返。
现在文案是:
该上游消息(relay_key: xxx)已转发过,本次调用未产生新邮件 —— 不需要重试。
## 判据(3 格,2 个变异都转红)
- send_mail 暴露 relay / relay_key(去掉透传 → 转红)
- relay 非空而 relay_key 为空必须被拒
- 同一 relay_key 第二次转发报重复,且**只有一封真邮件**
## 判据自己错了两次
1. `TestRelayRequiresKey` 红着但**原因不对**:没建收件人,400 发生在
「收件人不存在」那一步,掩盖了 parseRelay 的校验。
2. 幂等那格被我自己 `Skip` 了 —— `.relay-idem` 会话不存在,
于是「幂等没被击穿」的假绿。改为 `.new` 并**禁止 Skip**:
幂等是自动转发不重投的唯一保证,它绿着才敢让宿主上报。
(判据不可达 = 描述的状态不存在。)
## 双向对齐判据立刻证明了价值
加了 relay/relay_key 却没登记,它当场红:
★ send_mail 的参数 "relay" 既不在插件版、也没登记为 MCP 额外
若它不存在,加了新参数就会静默通过,而「这个能力是有意加的还是漏登记的」
正是以后没人能回答的问题。relay 登记为「额外」而非「缺失」——
插件侧走 session.idle,不需要这条参数。
14 包全绿。
458 lines
17 KiB
Go
458 lines
17 KiB
Go
package mcp
|
||
|
||
/*
|
||
改名提议的判据。
|
||
|
||
# 为什么这一层要单独钉
|
||
|
||
标记格式是**服务端正则的镜像**(`server/internal/handler/rename_proposal.go` 的
|
||
`renameProposalRe`)。格式不一致的后果是**静默**的:
|
||
|
||
邮件照常发出 → 服务端正则匹配不上 → 提议凭空消失
|
||
→ 模型以为自己提过了 → 下一封邮件用那个不存在的别名寻址 → 404
|
||
|
||
所以这里不只验「能拼出标记」,还验**与 JS 版逐字节相同**、且**能被服务端正则
|
||
真的解出来**(直接调用服务端那个正则,不是另写一个)。
|
||
*/
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
)
|
||
|
||
// 服务端的正则,在这里 import 而不是复制 —— 复制一份就等于放弃了「镜像」的意义。
|
||
// 这里直接用 handler 包里的那个(它是导出的)。
|
||
func serverExtract(body string) (alias, reason string, ok bool) {
|
||
for _, m := range renameProposalRe.FindAllStringSubmatch(body, -1) {
|
||
if len(m) >= 2 {
|
||
return m[1], func() string {
|
||
if len(m) > 2 {
|
||
return m[2]
|
||
}
|
||
return ""
|
||
}(), true
|
||
}
|
||
}
|
||
return "", "", false
|
||
}
|
||
|
||
func TestRenameProposalRoundTripsThroughServerRegex(t *testing.T) {
|
||
body, ok := appendRenameProposal("干完了,根因是登录态泄漏。", "fix-login-leak", "定位到泄漏点在 cookie 过期判断")
|
||
if !ok {
|
||
t.Fatal("合法别名必须被接受")
|
||
}
|
||
alias, reason, found := serverExtract(body)
|
||
if !found {
|
||
t.Fatalf("★ 服务端正则必须能解出标记(解不出 = 提议静默消失):body=%q", body)
|
||
}
|
||
if alias != "fix-login-leak" {
|
||
t.Errorf("alias=%q", alias)
|
||
}
|
||
if reason != "定位到泄漏点在 cookie 过期判断" {
|
||
t.Errorf("reason=%q", reason)
|
||
}
|
||
}
|
||
|
||
// 与 JS 版逐字节相同 —— 这是「跨语言实现同一份协议」的核心风险。
|
||
func TestRenameProposalMatchesJSByteForByte(t *testing.T) {
|
||
const NL = "\n\n" // 标记前是两个换行
|
||
cases := []struct{ body, alias, reason, want string }{
|
||
{"正文", "fix-a", "理由", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="理由" -->`},
|
||
{"正文", "fix-a", "", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" -->`},
|
||
// 理由里的双引号要去掉(HTML 注释里没有转义机制)
|
||
{"正文", "fix-a", `他说"好"`, "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="他说好" -->`},
|
||
}
|
||
for _, c := range cases {
|
||
got, ok := appendRenameProposal(c.body, c.alias, c.reason)
|
||
if !ok {
|
||
t.Fatalf("case %+v 应被接受", c)
|
||
}
|
||
if got != c.want {
|
||
t.Errorf("★ 与 JS 版不一致\n Go: %q\n JS: %q", got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 非法别名:原样返回、不追加标记,并如实告诉模型「没提交」。
|
||
func TestRenameProposalRejectsIllegalAlias(t *testing.T) {
|
||
for _, alias := range []string{"", " ", "new", "has.dot", "has space", "has/slash", "has@at", `has"quote`} {
|
||
body, ok := appendRenameProposal("正文", alias, "理由")
|
||
if ok {
|
||
t.Errorf("★ 非法别名 %q 被接受了(会发一个服务端匹配得上却被 validateSessionAlias 拒掉的标记)", alias)
|
||
}
|
||
if body != "正文" {
|
||
t.Errorf("别名 %q 非法时正文必须原样返回,实际 %q", alias, body)
|
||
}
|
||
// 空别名 = 根本没提议 ⇒ 不该有说明(与 JS 版一致)。
|
||
// 非空但不合法 ⇒ 必须说明「未提交」,否则模型以为自己提过了。
|
||
note := renameProposalNote("", alias, false)
|
||
trimmed := strings.TrimSpace(alias)
|
||
if trimmed != "" && note == "" {
|
||
t.Errorf("★ 别名 %q 不合法时必须给模型一句「未提交」的说明", alias)
|
||
}
|
||
if trimmed == "" && note != "" {
|
||
t.Errorf("别名是空的(没提议)时不该有说明,实际 %q", note)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回显必须用**服务端给的**别名:normalizeAlias 改过之后,本地值是不存在的名字。
|
||
func TestRenameProposalNoteUsesServerAlias(t *testing.T) {
|
||
// 服务端规范化了(new → session-new 之类)
|
||
note := renameProposalNote("fixed-login", "fix登录", true)
|
||
if !strings.Contains(note, "fixed-login") {
|
||
t.Errorf("★ 必须回显服务端返回的别名,实际 %q", note)
|
||
}
|
||
if !strings.Contains(note, "规范化") {
|
||
t.Errorf("本地值与服务端值不同时必须说明被规范化了,实际 %q", note)
|
||
}
|
||
// 必须说「等用户确认」—— 不说模型会以为已经生效,接着用新别名发信(那个别名还不存在)
|
||
if !strings.Contains(note, "确认") {
|
||
t.Errorf("必须说明要等人确认,实际 %q", note)
|
||
}
|
||
}
|
||
|
||
// 三种情形各有各的话,不能串。
|
||
func TestRenameProposalNoteThreeOutcomes(t *testing.T) {
|
||
if n := renameProposalNote("", "", false); n != "" {
|
||
t.Errorf("没有提议时不该有说明,实际 %q", n)
|
||
}
|
||
if n := renameProposalNote("", "fix-a", true); !strings.Contains(n, "未被服务端接受") {
|
||
t.Errorf("标记发出但服务端没回值 ⇒ 应说未被接受,实际 %q", n)
|
||
}
|
||
if n := renameProposalNote("fix-a", "fix-a", true); strings.Contains(n, "规范化") {
|
||
t.Errorf("两端一致时不该提规范化,实际 %q", n)
|
||
}
|
||
}
|
||
|
||
// ★ 与 JS 版的合法判据一致:超长别名拒绝。
|
||
func TestRenameProposalRejectsOverlongAlias(t *testing.T) {
|
||
long := strings.Repeat("a", 129)
|
||
if _, ok := appendRenameProposal("正文", long, ""); ok {
|
||
t.Error("★ 超 128 字节的别名必须拒绝(服务端是 VARCHAR(128))")
|
||
}
|
||
if _, ok := appendRenameProposal("正文", strings.Repeat("a", 128), ""); !ok {
|
||
t.Error("正好 128 字节应当接受")
|
||
}
|
||
}
|
||
|
||
// ---- 工具层接线 ----
|
||
|
||
// ★ 这一格钉的是「参数真的接上了」:上一版三个参数都缺(propose_alias、
|
||
//
|
||
// offset、session_alias),而工具照常工作 —— 缺参数不会报错,只会让模型
|
||
// 以为能力不存在。
|
||
/*
|
||
工具面必须与插件版**双向**对齐(2026-10-04)。
|
||
|
||
★ 为什么要改成真读源码:
|
||
初版这里是一份**手抄清单**(map[string][]string)。两个问题:
|
||
1. 会漂移 —— 桥加了参数,这里不会跟着变,判据照样全绿;
|
||
2. 只查"不缺",不查"不多" ⇒ MCP 多出来的能力没人管。
|
||
|
||
⇒ 改为**每次现读** plugins/pi-mail-bridge/src/tools.mjs,
|
||
与 MCP 的 schema 做双向集合比对。
|
||
|
||
已知的三处**有意**差异,各有理由,逐一显式豁免并说明:
|
||
- connect_to_server 的 gateway_url/key_token:
|
||
网关内建端点已由 AgentAuth 认证;改坐标是**部署动作**,
|
||
不该由一次工具调用触发(桥侧是局外进程才需要)。
|
||
- session_id(4 个工具):15e4fe9/095213b 越权修复的显式化产物。
|
||
插件侧靠「当前所在会话」隐式,MCP 宿主没有这个隐式上下文 ⇒ 必须显式。
|
||
- MCP 侧的 attachment_ids/propose_alias/propose_reason/flatten:
|
||
**MCP 比插件多**,属能力增强,不要求插件对齐。
|
||
*/
|
||
func TestToolsAlignWithPluginBridgeBidirectionally(t *testing.T) {
|
||
s := NewServer(nil)
|
||
NewTools().RegisterAll(s)
|
||
|
||
plugin := parsePiBridgeToolParams(t)
|
||
|
||
// 插件有而 MCP 没有的:必须逐个在豁免名单里有理由
|
||
intentional := map[string]map[string]string{
|
||
"connect_to_server": {
|
||
"gateway_url": "改坐标是部署动作,不该由工具调用触发",
|
||
"key_token": "身份只从 AgentAuth 的 context 取,不从参数取",
|
||
},
|
||
}
|
||
|
||
for tool, want := range plugin {
|
||
props, ok := s.tools[tool]
|
||
if !ok {
|
||
t.Errorf("★ 插件有工具 %q,MCP 没有", tool)
|
||
continue
|
||
}
|
||
sch, _ := props.Schema().InputSchema["properties"].(map[string]any)
|
||
for _, k := range want {
|
||
if _, has := sch[k]; has {
|
||
continue
|
||
}
|
||
if why, ok := intentional[tool][k]; ok {
|
||
_ = why // 有意豁免,见函数注释
|
||
continue
|
||
}
|
||
t.Errorf("★ %s 缺参数 %q —— 插件版有、MCP 没有,模型会以为该能力不存在", tool, k)
|
||
}
|
||
}
|
||
|
||
// 反向:MCP 独有的参数必须显式登记
|
||
mcpExtra := map[string][]string{
|
||
"forward_mail": {"session_id"},
|
||
"read_inbox": {"session_id", "workspace"},
|
||
"read_mail": {"session_id"},
|
||
"read_thread": {"session_id"},
|
||
"send_mail": {"attachment_ids", "propose_alias", "propose_reason",
|
||
"relay", "relay_key"},
|
||
"suggest_address": {"flatten"},
|
||
}
|
||
seen := map[string]bool{}
|
||
for tool, extra := range mcpExtra {
|
||
seen[tool] = true
|
||
props, ok := s.tools[tool]
|
||
if !ok {
|
||
t.Errorf("MCP 没有工具 %q", tool)
|
||
continue
|
||
}
|
||
sch, _ := props.Schema().InputSchema["properties"].(map[string]any)
|
||
for _, k := range extra {
|
||
if _, has := sch[k]; !has {
|
||
t.Errorf("★ 登记说 %s 有额外参数 %q,实际没有 —— 登记值过期了", tool, k)
|
||
}
|
||
}
|
||
}
|
||
// 反向还要反向查:MCP 有而没登记的参数(= 有人加了能力忘了登记)
|
||
for _, tl := range allTools() {
|
||
tool := tl.Schema().Name
|
||
sch, _ := tl.Schema().InputSchema["properties"].(map[string]any)
|
||
for k := range sch {
|
||
if k == "type" || k == "description" {
|
||
continue
|
||
}
|
||
if contains(append(append([]string{}, plugin[tool]...), mcpExtra[tool]...), k) {
|
||
continue
|
||
}
|
||
t.Errorf("★ %s 的参数 %q 既不在插件版、也没登记为 MCP 额外 —— "+
|
||
"新增能力忘了登记(判据会随实现漂移)", tool, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
// parsePiBridgeToolParams 真读 pi 桥源码,取每个工具 parameters.properties 的键。
|
||
//
|
||
// ★ 每次现读、绝不缓存:模块级缓存会让「改了桥不重读」变成假绿
|
||
//
|
||
// (这在本项目已犯过一次 —— 判据模块级缓存外部源码)。
|
||
//
|
||
// ★ 用**花括号深度**而不是「行正则找 `},`」来判块结束。
|
||
//
|
||
// 初版用 `^\s*\},?$` 判闭合,而 properties 里的**每一行参数**长得就是
|
||
// ` xxx: { type: … },` —— 首行参数就把块关掉了。实测:给桥加一个参数,
|
||
// 判据**依然全绿**(`out` 有 11 个 key、每个都是空数组,
|
||
// 而 `len(out)==0` 那个守卫恰恰检查不出来)。
|
||
//
|
||
// ⇒ 少一个「抓到几个参数」的断言,判据就会长期假绿而不自知。
|
||
func parsePiBridgeToolParams(t *testing.T) map[string][]string {
|
||
t.Helper()
|
||
path := filepath.Join("..", "..", "..", "plugins", "pi-mail-bridge", "src", "tools.mjs")
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Skipf("读不到 pi 桥源码(%v)—— 对齐判据无法运行", err)
|
||
}
|
||
out := map[string][]string{}
|
||
toolRe := regexp.MustCompile(`^\s*name:\s*'([a-z_]+)',?\s*$`)
|
||
propRe := regexp.MustCompile(`properties:\s*\{\s*$`)
|
||
paramRe := regexp.MustCompile(`^\s+([a-z_]+):\s*\{\s*type:`)
|
||
skipKeys := map[string]bool{
|
||
"type": true, "description": true, "enum": true,
|
||
"items": true, "default": true, "required": true,
|
||
}
|
||
|
||
var cur string
|
||
depth := 0
|
||
for _, ln := range strings.Split(string(raw), "\n") {
|
||
if m := toolRe.FindStringSubmatch(ln); m != nil {
|
||
cur = m[1]
|
||
out[cur] = []string{}
|
||
depth = 0
|
||
continue
|
||
}
|
||
if cur == "" {
|
||
continue
|
||
}
|
||
if depth == 0 && propRe.MatchString(ln) {
|
||
depth = 1 // 进入 properties
|
||
continue
|
||
}
|
||
if depth > 0 {
|
||
if m := paramRe.FindStringSubmatch(ln); m != nil && !skipKeys[m[1]] {
|
||
out[cur] = append(out[cur], m[1])
|
||
}
|
||
depth += strings.Count(ln, "{") - strings.Count(ln, "}")
|
||
if depth <= 0 {
|
||
depth = 0
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
// ★ 守卫要看**参数总数**,不是 key 数。
|
||
// 上面的 bug 正是「11 个 key、0 个参数」却通过了 len(out)==0 的检查。
|
||
total := 0
|
||
for _, v := range out {
|
||
total += len(v)
|
||
}
|
||
if total < 20 {
|
||
t.Skipf("pi 桥源码结构变了(只解析出 %d 个参数)—— 对齐判据会假绿", total)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ─── GET /mcp 事件流(投递/拉起对话)────────────────────────
|
||
|
||
/*
|
||
★ 方言必须是 JSON-RPC 通知。
|
||
|
||
这一格存在的理由:Frame 决定了接入方能不能解析。写错了不会报错 ——
|
||
连接是活的、字节在流动,只是客户端**解不开**而默默丢弃每一个事件。
|
||
那种失败看起来完全像「没有人给我发信」。
|
||
*/
|
||
func TestFrameRendersJSONRPCNotification(t *testing.T) {
|
||
out := Frame("7", "new_mail", []byte(`{"mail_id":"m1","subject":"你好"}`))
|
||
|
||
// ★ 三行 SSE 格式(2026-10-04 补)。
|
||
// 原实现只写裸 JSON,而 Content-Type 是 text/event-stream ⇒
|
||
// 标准 MCP 客户端(EventSource / eventsource-parser)按 `data:` 取
|
||
// payload,取不到就**静默丢掉每个事件**。
|
||
//
|
||
// 这一格初版只查「以 `{"jsonrpc"` 开头」—— 裸 JSON 恰好也满足,
|
||
// 于是**真正的坏帧一直绿灯**。教训与之前那次相同:
|
||
// 判据只断言了「能识别的那部分」,没断言「该有的结构」。
|
||
if !strings.HasPrefix(out, "id: 7\n") {
|
||
t.Errorf("★ 缺 SSE 的 id: 行(规范靠它维护 Last-Event-ID):%q", out)
|
||
}
|
||
if !strings.Contains(out, "\nevent: message\n") {
|
||
t.Errorf("★ 缺 SSE 的 event: 行:%q", out)
|
||
}
|
||
dl := strings.Index(out, "\ndata: ")
|
||
if dl < 0 {
|
||
t.Fatalf("★ 缺 data: 行 —— 标准 SSE 客户端会解析不到 payload:%q", out)
|
||
}
|
||
data := strings.TrimPrefix(out[dl+len("\ndata: "):], "")
|
||
data = strings.TrimRight(data, "\n")
|
||
|
||
// ★ 整帧必须是**合法 JSON**。
|
||
// 真实 bug(2026-10-04):`"params":` 后面少一个 `{`,
|
||
// 产出 `..."params":"event_id":"7",...}` —— params 变成字符串。
|
||
// 而上面的前缀检查、HasSuffix("\n\n")、payload 检查**全都通过**。
|
||
// 唯一能抓到它的办法是把整帧当 JSON 解一遍。
|
||
var probe struct {
|
||
JSONRPC string `json:"jsonrpc"`
|
||
Method string `json:"method"`
|
||
Params struct {
|
||
EventID string `json:"event_id"`
|
||
EventType string `json:"event_type"`
|
||
Payload json.RawMessage `json:"payload"`
|
||
} `json:"params"`
|
||
}
|
||
if err := json.Unmarshal([]byte(data), &probe); err != nil {
|
||
t.Fatalf("★ data: 行不是合法 JSON(%v):%s", err, data)
|
||
}
|
||
if probe.JSONRPC != "2.0" || probe.Method != "notifications/message" {
|
||
t.Errorf("必须是 JSON-RPC 2.0 通知:%s", data)
|
||
}
|
||
if probe.Params.EventID != "7" || probe.Params.EventType != "new_mail" {
|
||
t.Errorf("params 必须解出 event_id/event_type(说明 params 是对象不是字符串):%s", data)
|
||
}
|
||
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)
|
||
}
|
||
}
|