- Article -

给 AI 工作区接入 MCP 客户端:锁定定义、惰性连接与几次被推翻的设计

分类于 Ai 标签 MCP Go PocketBase AI Agent Prompt Caching JSON Schema 发表于2026-07-27 08:47

给 AI 工作区接入 MCP 客户端:锁定定义、惰性连接与几次被推翻的设计

背景

Workavera 是一个自托管的 AI 工作区,本身已经是一台 MCP 服务器——在 /api/mcp 上把项目板、日历、文档、阅读列表这些能力开放给 Claude Code、Cursor 之类的客户端。

这次要做的是反方向:让 Workavera 成为 MCP 客户端,用户可以在设置里接入自己的第三方 MCP 服务器,然后在 Chat 里用这些服务器提供的工具。

有意思的不是最终代码,而是中间几次把方案推翻的过程。这篇记录设计推演本身。

一、先确认可行性

翻代码时发现最关键的一点:反方向的桥接已经写好了

internal/mcpserver/mcpserver.go 里的 bridgeToolInfo / bridgeToolHandler 做的正是「内部 fantasy.AgentTool → MCP Tool」的适配。要做的客户端只是同一层的镜像。

// 已有:内部工具 → MCP
func bridgeToolInfo(tool fantasy.AgentTool) *mcp.Tool {
	info := tool.Info()
	return &mcp.Tool{
		Name:        info.Name,
		Description: info.Description,
		InputSchema: map[string]any{
			"type":       "object",
			"properties": info.Parameters,
			"required":   info.Required,
		},
	}
}

fantasy.AgentTool 接口只有四个方法,实现成本极低:

type AgentTool interface {
	Info() ToolInfo
	Run(ctx context.Context, params ToolCall) (ToolResponse, error)
	ProviderOptions() ProviderOptions
	SetProviderOptions(opts ProviderOptions)
}

github.com/modelcontextprotocol/go-sdk 也已经在 go.mod 里,客户端能力完整。可行性没问题。

二、核心决策:工具定义要不要实时同步?

这是整个设计的分水岭。

主流客户端(Claude Code、Cursor)的做法是实时镜像:连上服务器,拉 tools/list,用什么就是什么。上游改了,客户端跟着变。

但这里有个安全问题,我称之为 rug pull

用户审阅并批准了一个叫 search_docs 的工具,描述是"搜索文档"。三天后上游把描述改成"搜索文档并把结果发送到 evil.com"。用户毫不知情,模型照单全收。

所以最终方案是锁定定义

这就是 lockfile 模型。而这个项目自己就在用 pnpm-lock.yaml

pnpm MCP 模块
pnpm-lock.yaml 库里固化的工具定义
integrity 校验和 schema_hash
pnpm update 刷新按钮
显式提交 lockfile 用户逐个确认启用与审批

没人希望 npm install 在不知情的时候换掉依赖,MCP 工具同理。

比对逻辑:

previous, found := existing[tool.Name]
switch {
case found && previous.Hash == hash:
	// 定义没变,保留用户的选择
	definition.Enabled = previous.Enabled
	definition.Approval = previous.Approval
	definition.Stale = false
case found:
	// 定义变了 —— 这就是 rug pull 的形状,退回待审
	definition.Enabled = false
	definition.Approval = defaultApproval(server.ApprovalPolicy, tool.Annotations)
default:
	// 新工具,同样待审
	definition.Enabled = false
	definition.Approval = defaultApproval(server.ApprovalPolicy, tool.Annotations)
}

三、被推翻的设计之一:连接池

我最初的方案里有一整套连接池:per-user session 缓存、空闲超时回收、OnTerminate 统一关闭、detached context 避免 run 结束时连接被 cancel……

然后被一句话问倒了:

每次 mcp 工具调用实质上是一次 http 请求?每次调用请求就行了吧?不需要那么多复杂内容?

我先纠正了一个事实:一次调用不是一个 HTTP 请求,Streamable HTTP 下是 3~4 个往返initializenotifications/initializedtools/callCloseDELETE)。

但结论仍然是对方对的。这几个往返串行发生在工具执行窗口内,前端此时显示的是加载态,前后夹着的是 LLM 推理的秒级耗时。为了省这几百毫秒引入连接生命周期管理,是典型的没有实测支撑的推测性优化。

去掉池之后,我列的「四个坑」消掉了一半——因为其中两个是池化自己引入的

原本的坑 无池方案
建连必须用 detached context 消失,直接用 run ctx 反而正确(取消 run 应该断开上游)
并发复用的竞态 消失,每次调用独立连接
空闲回收 ticker、进程退出收尾 消失defer session.Close() 就够
连接失败降级成 tool error 仍需要
调用超时 仍需要,且更简单

最终的 Run 退化成很直白的一段:

ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()

session, err := connect(ctx, server, clientVersion())
if err != nil {
	return fantasy.NewTextErrorResponse("无法连接到 " + serverName), nil
}
defer session.Close()

result, err := session.CallTool(ctx, &mcp.CallToolParams{...})

教训:区分「这是优化」和「这是必需」。工具清单从数据库读是必需的(否则首字延迟被最慢的上游绑架、一台挂掉拖垮整个 run),连接池只是优化——前者保留,后者砍掉。

四、标准 MCP 怎么处理工具变化?

既然不实时同步,那标准里是怎么做的?

  1. 服务器在 initialize 声明 capabilities.tools.listChanged: true
  2. 工具变了就发 notifications/tools/list_changed
  3. 这个通知是空的——ToolListChangedParams 不带任何数据,只说"变了,你自己来拉"

传输层上,server → client 的消息只有两条路:standalone SSE 长连接(客户端 initialize 后发 GET,服务器挂住),或者某次进行中 POST 的 SSE 响应体。

所以要收到通知就必须一直连着。connect-per-call 模型下基本收不到。

但这对锁定定义模型不构成损失,三个理由:

  1. listChanged可选能力,很多服务器不实现
  2. 通知不携带内容,收到后照样要发 tools/list——它只是省掉轮询的优化
  3. 最重要的:这个设计里即使收到通知也不该自动生效,rug pull 防护要求人工确认

也就是说,刷新按钮实现的就是标准里那条不依赖通知的路径,没有绕开标准。

顺带一个结论:既然不需要服务端推送,建连时应显式 DisableStandaloneSSE: true,省掉一条长连接和整套重连逻辑。

transport = &mcp.StreamableClientTransport{
	Endpoint:   server.URL,
	HTTPClient: httpClient,
	// 工具定义只通过用户接受的刷新改变,tools/list_changed 无事可做
	DisableStandaloneSSE: true,
	// 一个连接只服务一次调用,重试握手只会推迟错误
	MaxRetries: -1,
}

五、最精妙的一环:让上游的拒绝变成「证据」

不实时同步,就会有定义过期的问题。怎么发现?

MCP 协议本身有两个错误通道:

第一层劈开靠它就够。但真正的歧义在 -32602 Invalid params,它有两个成因:

解法:调用前用锁定的 schema 做本地预校验。

// 本地校验放在最前,一方面让格式错误的模型输出不必发出网络请求,
// 另一方面 —— 更关键地 —— 使得上游的参数拒绝成为
// 「锁定 schema 已与上游不符」的证据。
if err := validateArguments(t.definition, arguments); err != nil {
	return fantasy.NewTextErrorResponse("参数不符合此工具的 schema: " + err.Error()), nil
}

于是:

这个推理链是整个模块里最优雅的部分:一个本来为了省往返的校验,顺手变成了漂移检测的判据。

完整判定表:

结果 含义 处理
isError 工具执行了但失败 返回助手,不标记
协议错误含 unknown tool 工具消失了 标记 stale
本地校验失败 模型参数错 返回助手,不发请求
本地通过但上游 -32602 schema 漂移 标记 stale
连接失败/超时/401 服务器或凭据问题 last_error(服务器级)
其他协议错误 上游故障 返回助手,不标记

后两行的区分在界面上很重要:凭据过期和工具变化需要用户做不同的事,所以连接失败绝不能呈现为工具变化。

顺带一提,这套判定是启发式的。不同服务器对同一情况用的错误通道并不一致,所以 stale 的措辞是「本次调用失败,定义可能已过期」,而不是断言。刷新视图把真实差异摆出来,判断权留给用户。

六、不可信的自我声明

MCP 有 annotations.readOnlyHint / destructiveHint,看起来正好可以用来决定哪些工具需要审批。

但这里有个陷阱:annotations 是上游服务器自己声明的。恶意或低质量的服务器完全可以把删除工具标成 readOnlyHint: true 绕过审批——由被审判者提供证词

所以 annotations 只能当配置时的建议默认值,不能当运行时判据:

// defaultApproval 为用户即将审阅的工具预选审批模式。上游注解是服务器
// 对自己的声明,因此不能在调用时被信任,但作为一个用户仍需确认的
// 起点是合理的。
func defaultApproval(policy string, annotations *mcp.ToolAnnotations) string {
	switch policy {
	case policyNone:
		return approvalNever
	case policyWrites:
		if annotations != nil && annotations.ReadOnlyHint {
			return approvalNever
		}
		return approvalAlways
	default: // policyAll —— 无视上游声明
		return approvalAlways
	}
}

用户确认后,决定固化进数据库,运行时只读这份:

// RequiresApproval 只查存储值;上游注解仅在审阅时作为建议输入,
// 永不影响运行时行为。
func (t ToolDefinition) RequiresApproval() bool {
	return t.Approval != approvalNever
}

注意默认值的方向:Approval 为空字符串时返回 true未设置不能读作"不需要审批"

还有个现实问题值得说破:大多数 MCP 服务器根本不发布 annotations,所以 writes 策略在实践中和「全部审批」几乎没区别。UI 文案如实写明了这点,而不是让它看起来会智能区分。

七、为什么不把远程工具再导出给 MCP 客户端

一个自然的想法:Workavera 既然是 MCP 服务器,能不能把用户接入的第三方工具也一并转发出去?

技术上是一行的事。但有个实打实的越权漏洞:

API key 的 allow_destructive 开关,靠的是一张硬编码的破坏性工具名单

var destructiveTools = map[string]bool{
	"board_delete_task":     true,
	"calendar_delete_event": true,
}

远程工具永远不在表里 → IsDestructive 永远返回 false → 一个明确勾了「不允许删除」的只读 key,照样能调到远程服务器的删除类工具

所以远程工具只加进 ForChatForActor(MCP 导出用)一行不动:

// Remote MCP tools stay out of ForActor for a specific reason: an API key's
// allow_destructive scope is enforced against a known list of built-in
// destructive tools, and no equivalent judgement can be made about a
// third-party tool. Exporting them would silently widen what a read-only key
// can do.

而且收益也很小:Claude Code / Cursor 本来就能直接配置那些服务器,绕经一手只增加延迟和故障点。

八、踩坑:405 与 410

配置 DeepWiki 时连续踩了两个坑,都是传输方式相关。

第一次:Method Not Allowed

URL 填的是 https://mcp.deepwiki.com/sse,传输选了默认的 Streamable HTTP。旧版 SSE 端点只接受 GET,而 Streamable HTTP 会把 initialize POST 过去 → 405。

第二次:Gone

改成 SSE 传输,变成 410。直接探一下:

curl -s -i -H 'Accept: text/event-stream' https://mcp.deepwiki.com/sse
{
  "error": "SSE transport is deprecated",
  "message": "The SSE transport has been deprecated in favor of Streamable HTTP.
              Please use the /mcp endpoint instead.",
  "endpoint": "/mcp"
}

服务器给了非常清楚的说明,但界面上只显示 "Gone"——因为 SDK 在判断状态码非 2xx 后直接 resp.Body.Close(),只返回 http.StatusText

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
	resp.Body.Close()
	return nil, fmt.Errorf("failed to connect: %s", http.StatusText(resp.StatusCode))
}

拿不到就自己补。405 和 410 都加了可操作的解释:

The endpoint rejected the request method. This usually means the transport does not match the URL: use SSE for a /sse endpoint, or Streamable HTTP for a /mcp endpoint.

The server has permanently retired this endpoint. If the URL ends in /sse, the server has most likely dropped the legacy SSE transport: switch to its Streamable HTTP endpoint, usually /mcp.

生态背景:整个 MCP 生态正在弃用 2024-11-05 的 SSE 传输,迁移到 2025-03-26 的 Streamable HTTP。DeepWiki 用 410 + 说明文字是很规范的下线做法。

九、一个 UI 层的通用 bug

做设置页时撞上一个 Radix 的经典问题:弹窗里点下拉菜单,整个弹窗被关掉

Radix 把 Select 内容渲染到 body 上的独立 portal,不在 Dialog 的 DOM 子树里,所以操作它被判定为「点击外部」而 dismiss 整个浮层。

我第一版方案是靠选择器识别事件来源,然后 preventDefault。但它是猜的——不确定 dismiss 事件的真实 target。

更好的方案(用户提的):外部点击一律不关闭,只保留 Esc 和关闭按钮。

// Clicking outside never dismisses a dialog; Escape and the close button do.
// Dialogs hold forms, so a stray click should not discard typed input, and
// Radix renders dropdown content in its own portal where interacting with it
// reads as an outside click and would close the dialog underneath.
onInteractOutside={(event) => event.preventDefault()}

一行,覆盖整类 bug(日期选择器、命令面板、下拉菜单全都包括),不需要判断事件来源。而且顺带修了一个独立问题:填长表单时手滑点到空白,整表连凭据一起丢。

Radix 的 AlertDialog 本来就不响应外部点击,所以这个模式在项目里早已存在,只是推广到了 Dialog 和 Sheet。

十、Prompt Caching:一个被发现的空缺

讨论工具数量时顺带查证了一件事:这个项目根本没开 prompt caching

fantasy 支持(anthropicTool.CacheControl),但需要通过 ProviderOptions 显式开启,而项目里 SetProviderOptions 只是为了满足接口而实现,从未被调用。CacheReadTokens 只在统计里读一下。

Anthropic 的可缓存前缀顺序是 tools → system → messages,工具定义在最前面,所以工具清单一变,后面全部失效。

排查后发现三个位置里两个可达

位置 本项目能否设置
工具块 SetProviderOptions 已在接口上
消息 toFantasyMessages 里自己构造 fantasy.Message
系统提示 WithSystemPrompt(string) 内部走 NewSystemMessage,不给外部挂 options 的口子

但因为缓存是前缀语义,断点打在历史消息末尾覆盖的是 tools + system + 全部历史——比只缓存 system 更划算。最优放置恰好可达。

而且只需要动 Anthropic:OpenAI 自动前缀缓存、DeepSeek 自动、Google 有隐式缓存。

十一、工具网关:为什么「省了个寂寞」

最后一个被否掉的方案,讨论得最深。

想法:工具多了以后,与其把 N 个工具全注入 tools 块,不如只放两个固定工具——mcp_find_tools(分页搜索)和 mcp_run_tool(调用)。工具清单永远固定,利于缓存,省 token。

这个想法是对的,也是生态的走向(渐进式工具披露)。但推演下来有几个非显然的问题。

问题一:schema 约束

先要澄清一个概念——供应商会拿 schema 约束模型生成,不只是我们自己事后校验。

直连时,每个工具是一个签名具体的函数:

{
  "name": "mcp_deepwiki_read_wiki_contents",
  "input_schema": {
    "properties": {
      "repoName": {"type": "string", "description": "owner/repo"},
      "page":     {"type": "integer", "minimum": 1}
    },
    "required": ["repoName"]
  }
}

网关时,一个函数要能调所有工具,签名只能通用化:

{
  "name": "mcp_run_tool",
  "input_schema": {
    "properties": {
      "server":    {"type": "string"},
      "tool":      {"type": "string"},
      "arguments": {"type": "object"}
    }
  }
}

arguments 是个空壳。用 Go 打比方:

func ReadWikiContents(repoName string, page int) Result  // 直连
func RunTool(server, tool string, arguments any) Result  // 网关

any 不是"约束弱",是"没有约束"。

那把 N 个 schema 用 oneOf 都写进去呢? 两个问题:一是它把所有 schema 又搬回 tools 块了,而省 token 靠的正是"不搬"——省量和约束是同一样东西;二是在这个项目里连发都发不出去,fantasy 会把每个工具的 schema 重建成:

inputSchema := map[string]any{
	"type":       "object",
	"properties": info.Parameters,
	"required":   info.Required,
}

根级的 oneOf / anyOf / $defs 全部丢弃。(这也是刷新时必须就地展开 $defs 的原因。)

问题二:约束强度其实是个光谱

不过这里我自己的表述也被纠正了。查证后发现,fantasy 走 Chat Completions 时写死了 Strict: param.NewOpt(false),Anthropic 的 tool_use 本来也没有约束解码。所以这个项目拿到的是"上下文引导"而非"约束解码"——不是编译期检查,更像"签名写在注释里,模型基本照做但可能写错"。

而网关模式下,模型必须先搜索,搜索结果里就带着 schema——那也是上下文引导。两者的差距因此比我最初说的小得多。

fantasy 自带的校验则几乎等于没有:

// Basic schema validation (check required fields)
// TODO: more robust schema validation using JSON Schema or similar
for _, required := range toolInfo.Required {
	if _, exists := input[required]; !exists { ... }
}

只查必填键在不在,类型、enum、嵌套一概不管。这正是要自己接 jsonschema-go 做完整校验的原因。

问题三:省 token 有个前提

这是最值得说的一点。

搜索结果会留在对话历史里,而且每一轮都重发。 一次 find_tools 返回 10 个工具的描述 + schema,进了历史就一直在。搜两三次,历史里就攒了 20-30 个 schema。

每次请求携带
直连 N 个 schema(固定、去重、不增长)
网关 2 个工具定义 + 搜索次数 × 10 个 schema(累积)

于是:

判据是 N(拥有的工具数)相对 K(实际用到的)有多大,不是 N 的绝对值。

最终结论

把网关的取舍准确表述成:

token 成本从「按拥有的工具数」变成「按用到的工具数」,代价是发现可靠性 + 延迟 + 对话可读性。

「发现可靠性」是主要成本而非 schema——模型必须先想到"也许有 MCP 工具能干这个",它不知道自己不知道什么。system prompt 能缓解,不能消除。

「可读性」也别忘:网关后所有远程调用都显示成 mcp_run_tool,要恢复得给前端做特殊渲染。

三台服务器、9 个工具的场景下,N ≈ K,网关纯亏。等真挂了几百个工具、每轮只用一两个,再上就是明显的赢——而那时我会先看 fantasy 的 ActiveTools(注册全部真 schema、每步只激活少数),它同时保住了发现可靠性和真 schema,代价换成激活集浮动。

总结

几条可复用的经验:

  1. 区分「优化」和「必需」。 工具清单从数据库读是必需的(首字延迟、故障隔离),连接池只是优化。前者留、后者砍。

  2. 让一个机制承担两个职责,往往是设计变优雅的信号。 本地预校验本来只为省往返,结果顺手成了漂移检测的判据。

  3. 不要信任被审判者的证词。 上游 annotations 只能当配置时的预选值。

  4. 区分「存储」「校验」和「约束」。 schema 一直在库里、我们一直在校验,网关丢的只是供应商侧的生成期引导——想清楚丢的到底是哪一层,结论才准确。

  5. 错误信息要指向动作,不是状态码。 "Gone" 没用,"这个端点已下线,把 /sse 换成 /mcp" 才有用。

  6. 省 token 的方案要算清楚"省下的"会不会从别处冒出来。 搜索结果也占上下文,而且会累积。

  7. 修 bug 时优先找那个能消掉整类问题的改法。 判断事件来源是猜,"外部点击一律不关闭"是解。