- 新增依赖: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)
324 lines
11 KiB
Go
324 lines
11 KiB
Go
// 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
|
||
}
|