Files
AskaEth a1083a322f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(mcp): 协议层换成官方 Model Context Protocol SDK,删自研实现(裁决 10.4.6 + AGENTS §9.5)
- 新增依赖:github.com/modelcontextprotocol/go-sdk v1.8.0(direct)+ github.com/google/jsonschema-go v0.4.3
  (SDK 自带依赖,直接用于 schema 校验/默认值,避免再造一份);go.sum 纯新增
- 删除自研协议层:rpc.go / stdio.go / http.go / sse.go 及其测试
- 用 SDK 的成熟逻辑:命令传输(stdio 子进程)/ SSE / Streamable HTTP、协议版本协商与
  -32022 回退、keepalive(含失败阈值)、tools/list_changed 订阅、能力协商(InitializeResult.Capabilities)
- 保留自研的四件事(其余零自研):配置→传输、发现→registry 注册、能力族推断、schema 缓存(Prepare 命中不重复解析)
- 能力闸诚实化:服务器声明 capabilities 但不含 tools 时 ListTools/DiscoverTools 返回
  ErrCapabilityNotDeclared(不静默空集);完全不声明 capabilities 的老式服务器仍按 legacy 处理
- 复核:build 绿 + capability 全包测试绿(mcp 42 用例,含真子进程 stdio / httptest Streamable HTTP /
  内存传输三类假 server,不依赖外网)+ `-race` 干净;变异 2 条(featuresFrom 恒 true / 删 applySchema 调用)已还原
- 无 REST/WS/env/数据目录变更;包内 API 有增删(见回报),仓外调用方签名未变(未改 cmd/cyrene 与 plugin/manager)
2026-10-04 13:53:47 +08:00

324 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package mcp 用官方 Model Context Protocol Go SDK 接入外部 MCP server,
// 并把服务器暴露的工具自动注册进 capability registry。
//
// 协议层(协议版本协商、stdio / SSE / Streamable HTTP 传输、keepalive ping、
// notifications/tools/list_changed 订阅)全部交给官方 SDK
// (github.com/modelcontextprotocol/go-sdk/mcp)——本包不自造协议,只做四件事:
//
// 1. 配置 → 传输选择(MCPConfig → SDK Transport,含 APIKey 鉴权);
// 2. 工具自动发现 → 注册进 capability registry(Registry);
// 3. 保留服务器声明的 capabilities 并推断可用能力族(ServerFeatures,
// 裁决 10.4.6 / design/09 §1.1「声明了才算可用」);
// 4. tools/list input schema 缓存(SchemaCache)+ 调用前校验与默认值填充。
//
// 典型接法:
//
// reg := registry.New()
// mcpReg := mcp.NewRegistry(reg, mcp.MCPConfig{Name: "calc", Command: "mcp-calc"})
// if err := mcpReg.Register(ctx); err != nil {
// log.Warn("mcp server skipped", "error", err)
// }
// defer mcpReg.Unregister(ctx)
//
// 注册后的工具就是普通 sdk.Tool:Dispatcher 每轮实时读 registry,
// 因此热接入的 MCP 工具下一轮模型请求即作为 Request.Tools 注入,模型可直接调用。
package mcp
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"sync"
"sync/atomic"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// clientName / clientVersion 是 initialize 时上报给 MCP server 的 clientInfo。
const (
clientName = "cyrene"
clientVersion = "0.1.0"
)
// keepAliveFailureThreshold 是 keepalive 关闭会话前容忍的连续 ping 失败次数默认值。
const keepAliveFailureThreshold = 3
// 本包对外哨兵错误,一律用 errors.Is 判定。
var (
// ErrNotConnected 表示操作要求已建立的连接,但客户端尚未连接或已关闭。
ErrNotConnected = errors.New("mcp: not connected")
// ErrClosed 表示在已关闭的客户端上执行操作。
ErrClosed = errors.New("mcp: closed")
// ErrNoTransport 表示配置既没有 Command 也没有 URL,选不出传输。
ErrNoTransport = errors.New("mcp: no transport configured")
// ErrUnsupportedProtocolVersion 表示客户端与服务器无法就协议版本达成一致。
ErrUnsupportedProtocolVersion = errors.New("mcp: unsupported protocol version")
// ErrCapabilityNotDeclared 表示调用了服务器在 initialize 里未声明的能力族
// (design/09 §1.1「声明了才算可用,未声明不得调用」)。
ErrCapabilityNotDeclared = errors.New("mcp: capability not declared by server")
)
// MCPConfig 描述一台 MCP server 的连接方式。
// TransportType 非空时按其选择传输;为空则沿用历史推断:
// 有 Command 走 stdio,有 URL 走 SSE(TransportType 可显式改走 Streamable HTTP)。
type MCPConfig struct {
// Name 是服务器标签,用于日志与工具去重前缀。
Name string `env:"MCP_NAME"`
// Command 是 stdio 模式下要拉起的可执行文件。
Command string `env:"MCP_COMMAND"`
// Args 是传给 Command 的参数。
Args []string `env:"MCP_ARGS"`
// URL 是 SSE 端点(TransportType="sse")或 Streamable HTTP 端点("http"/"streamable")。
URL string `env:"MCP_URL"`
// TransportType 显式选择传输:"stdio"、"sse"、"http"(等价 "streamable")。
TransportType string `env:"MCP_TRANSPORT"`
// APIKey 是 http/sse 服务器用的 bearer 凭据,作为 Authorization 头发送。
APIKey string `env:"MCP_API_KEY"`
// Env 是 stdio 子进程的额外环境变量,叠加在父进程环境之上(同名以这里为准)。
Env map[string]string `env:"MCP_ENV"`
// Enabled 表示该服务器是否在启动时接入。
Enabled bool `env:"MCP_ENABLED"`
}
// Config 是一组 MCP server 配置。
type Config struct {
// Servers 是按顺序排列的服务器配置。
Servers []MCPConfig
}
// ServerFeatures 是从服务器 initialize 声明里推断出的能力族可用性。
// 规则见 featuresFrom:声明了才算可用,未声明不得调用(design/09 §1.1)。
type ServerFeatures struct {
// Tools 表示服务器声明提供工具(tools/list、tools/call 可用)。
Tools bool
// Prompts 表示服务器声明提供提示模板。
Prompts bool
// Resources 表示服务器声明提供资源。
Resources bool
// Logging 表示服务器声明会发日志消息。
Logging bool
// Completions 表示服务器声明支持参数自动补全。
Completions bool
// Sampling 表示允许服务器回调宿主代跑 LLM:宿主配了回调,
// 且服务器声明过能力族(即确实是会主动交互的对端)才为真。
Sampling bool
// Elicitation 表示允许服务器回调宿主向用户追加提问,判定同 Sampling。
Elicitation bool
// Roots 表示允许向服务器暴露工作区根目录,判定同 Sampling。
Roots bool
}
// ServerInfo 是 initialize 握手的结果,服务器声明的 capabilities 原样保留。
type ServerInfo struct {
// Name 是服务器自报名称。
Name string
// Version 是服务器自报版本。
Version string
// Instructions 是服务器给出的使用说明(可注入系统提示)。
Instructions string
// ProtocolVersion 是协商后实际使用的 MCP 协议版本。
ProtocolVersion string
// Capabilities 是服务器声明的 capabilities 原文(未做删改),对客户端可读。
Capabilities map[string]any
// Features 是按 Capabilities 推断出的可用能力族。
Features ServerFeatures
}
// SamplingHandler 由宿主提供:服务器请求客户端代跑一次 LLM(sampling/createMessage)时调用。
type SamplingHandler func(context.Context, *mcpsdk.CreateMessageRequest) (*mcpsdk.CreateMessageResult, error)
// ElicitationHandler 由宿主提供:服务器向用户索要补充输入(elicitation/create)时调用。
type ElicitationHandler func(context.Context, *mcpsdk.ElicitRequest) (*mcpsdk.ElicitResult, error)
// Client 是一台 MCP server 的客户端。它内部持有一个官方 SDK 会话,
// 并发安全:Connect/Close 互斥,其余调用由 SDK 串行化。
type Client struct {
cfg MCPConfig
log *slog.Logger
mu sync.Mutex
state state
session *mcpsdk.ClientSession
info ServerInfo
// tr 是注入或按 cfg 选出的传输;schemas 是 tools/list input schema 缓存。
tr mcpsdk.Transport
schemas *SchemaCache
// preferredProtocolVersion 非空时固定 initialize 请求的协议版本(测试/排障用)。
preferredProtocolVersion string
// keepAlive 是 keepalive ping 间隔,零值关闭;keepAliveFailures 是容忍失败数。
keepAlive time.Duration
keepAliveFailures int
sampling SamplingHandler
elicitation ElicitationHandler
roots []*mcpsdk.Root
// onToolListChanged 在服务器发 notifications/tools/list_changed 时被调用
// (新 goroutine 里,已被 refreshPending 合并),用于增量刷新 registry。
onToolListChanged func()
refreshPending atomic.Bool
}
// state 是客户端生命周期。
type state int
const (
stateNew state = iota
stateConnected
stateClosed
)
// Option 定制 New 构建的 Client。
type Option func(*Client)
// WithLogger 覆盖客户端日志器;给定的日志器会被标上 source=capability/mcp。
func WithLogger(l *slog.Logger) Option {
return func(c *Client) {
if l != nil {
c.log = logger.WithSource(l, "capability/mcp")
}
}
}
// WithTransport 注入传输(官方 SDK 的 Transport),主要用于测试。
func WithTransport(t mcpsdk.Transport) Option {
return func(c *Client) {
c.tr = t
}
}
// WithKeepAlive 打开 keepalive:每 interval 发一次 ping,连续失败 keepAliveFailures
// 次即关闭会话(由 SDK 执行)。间隔为零则关闭 keepalive;失败次数缺省用包默认值。
func WithKeepAlive(interval time.Duration, keepAliveFailures ...int) Option {
failures := keepAliveFailureThreshold
if len(keepAliveFailures) > 0 && keepAliveFailures[0] > 0 {
failures = keepAliveFailures[0]
}
return func(c *Client) {
c.keepAlive = interval
c.keepAliveFailures = failures
}
}
// WithToolListChangedHandler 注册工具清单变更回调,由 registry 用于增量刷新。
func WithToolListChangedHandler(fn func()) Option {
return func(c *Client) {
c.onToolListChanged = fn
}
}
// WithProtocolVersion 固定 initialize 请求的协议版本;不设置则用 SDK 最新版本,
// 服务器拒绝时由 SDK 自动回退到双方都支持的版本。
func WithProtocolVersion(v string) Option {
return func(c *Client) {
c.preferredProtocolVersion = v
}
}
// WithSchemaCache 复用外部 schema 缓存;不设置则每个客户端自建一份。
func WithSchemaCache(cache *SchemaCache) Option {
return func(c *Client) {
if cache != nil {
c.schemas = cache
}
}
}
// WithSamplingHandler 注册 sampling 回调。设了才向服务器宣告 sampling 能力;
// 服务器未声明能力族时回调会被拒绝(ErrCapabilityNotDeclared)。
func WithSamplingHandler(h SamplingHandler) Option {
return func(c *Client) {
c.sampling = h
}
}
// WithElicitationHandler 注册 elicitation 回调。设了才向服务器宣告 elicitation 能力。
func WithElicitationHandler(h ElicitationHandler) Option {
return func(c *Client) {
c.elicitation = h
}
}
// WithRoots 注册工作区根目录。设了才向服务器宣告 roots 能力。
func WithRoots(roots ...*mcpsdk.Root) Option {
return func(c *Client) {
c.roots = append([]*mcpsdk.Root(nil), roots...)
}
}
// New 构建 cfg 对应的 Client,默认用丢弃日志器并自动选传输;
// 用 WithLogger / WithTransport 覆盖。
func New(cfg MCPConfig, opts ...Option) *Client {
c := &Client{
cfg: cfg,
log: defaultLogger(),
state: stateNew,
schemas: NewSchemaCache(),
}
for _, opt := range opts {
opt(c)
}
return c
}
// defaultLogger 返回带 capability/mcp 来源标签的丢弃日志器;宿主应传 WithLogger。
func defaultLogger() *slog.Logger {
base := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn}))
return logger.WithSource(base, "capability/mcp")
}
// Name 返回服务器标签。
func (c *Client) Name() string { return c.cfg.Name }
// ServerInfo 返回 initialize 握手结果;未连接时为零值。
func (c *Client) ServerInfo() ServerInfo {
c.mu.Lock()
defer c.mu.Unlock()
return c.info
}
// Features 返回按服务器声明推断出的能力族可用性;未连接时全为 false。
func (c *Client) Features() ServerFeatures {
c.mu.Lock()
defer c.mu.Unlock()
return c.info.Features
}
// SchemaCache 返回该客户端使用的 input schema 缓存。
func (c *Client) SchemaCache() *SchemaCache { return c.schemas }
// Session 返回底层官方 SDK 会话(未连接时为 nil),供需要 SDK 原生能力的调用方使用。
func (c *Client) Session() *mcpsdk.ClientSession {
c.mu.Lock()
defer c.mu.Unlock()
if c.state != stateConnected {
return nil
}
return c.session
}
// connected 返回已建立的 SDK 会话,未连接时返回 nil。
func (c *Client) connected() *mcpsdk.ClientSession {
return c.Session()
}
// begin 做 Connect 前的状态检查:已连接返回 same=true(幂等),已关闭返回错误。
func (c *Client) begin() (same bool, err error) {
c.mu.Lock()
defer c.mu.Unlock()
switch c.state {
case stateClosed:
return false, fmt.Errorf("mcp connect %q: %w", c.cfg.Name, ErrClosed)
case stateConnected:
return true, nil
}
return false, nil
}