Files
Cyrene_v2/internal/capability/mcp/mcp.go
T
AskaEth 70c9fba529
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(plugin): 插件热安装(MCP server) — POST /admin/plugins/install 连接+发现+注册工具进registry+落盘plugins.json
- capability/plugin/manager.go: Manager(Install/Uninstall/List/Restore/Close); Install=连接+发现+mcp:name注册工具,失败不落盘; holder持有*mcp.Registry; boot Restore重装
- config/runtimeconfig/plugins.go: PluginConfig(Normalize/Validate), LoadPlugins/SavePlugins(0600+tmp+rename)/Upsert/Delete/Masked(apiKey+敏感env掩码)
- mcp: MCPConfig加APIKey/Env, http/sse Bearer应用, stdio子进程env合并; registry.ToolNames()
- gateway: adminPluginInstall/Uninstall/ListInstalledPlugins; Deps.Plugins注入
- cmd/cyrene/plugins.go: wirePlugins(boot Restore+close)
- 契约: body{kind:mcp,name,config{transport, endpoint?/command?,args?,apiKey?,env?}} → {installed,name,kind,transport,toolCount}; 失败502不落盘/非法400/未注入501; 卸载连带unregister+删配置
- 测试(manager/plugins/admin/mcp_auth/组合根), build/vet/test绿+3平台交叉编译
- Docs/api-client v0.28 + api-reference install=501改真实; .gitignore加plugins.json
- 前端api.ts仍发旧body(无config)会400, 面板安装表单待后续
2026-09-08 23:20:12 +08:00

409 lines
15 KiB
Go

// Package mcp provides a minimal Model Context Protocol (MCP) client and the
// auto-registration glue that surfaces MCP server tools as capabilities.
//
// An MCP server is an external tool provider. This package speaks JSON-RPC 2.0
// to it over stdio (a spawned subprocess), SSE or Streamable HTTP, discovers the
// tools it exposes via tools/list, maps each one to a pkg/plugins/sdk Tool, and
// registers them into the capability registry so any character can invoke them
// (DevDocs/design/09 插件生态 §1).
//
// The client aligns with the MCP Go SDK's mature behaviour: it negotiates the
// protocol version during initialize (requesting the newest version first and
// downgrading to a mutually supported one), subscribes to
// notifications/tools/list_changed via its capabilities so the registry can
// re-sync its tool set, and runs an optional ping keepalive that closes a dead
// session so the host can rebuild it.
//
// It is deliberately dependency-light: only the standard library and the
// project packages (plugins/sdk, capability/registry, internal/logger) are
// used. The wire protocol is implemented directly, so no third-party MCP SDK is
// required.
//
// A caller usually wires it up as:
//
// reg := registry.New()
// mcpReg := mcp.New(reg, mcp.MCPConfig{Name: "calc", Command: "mcp-calc"})
// if err := mcpReg.Register(ctx); err != nil {
// log.Fatal(err)
// }
// defer mcpReg.Unregister(ctx)
//
// which connects to the server, discovers its tools, and registers them. Call
// sites then invoke the tools by their MCP name through the registry.
package mcp
import (
"context"
"errors"
"io"
"log/slog"
"slices"
"sync"
"sync/atomic"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// MCP protocol versions this client can speak, newest first. The client advertises
// its preferred version (latestProtocolVersion) during initialize and accepts a
// compatible version from the server, downgrading to a mutually supported version
// when the server rejects or cannot match the request (DevDocs/design/09 §1.1).
const (
// protocolVersion20251125 is the current preferred protocol version (the
// MCP 2025-11-25 revision that introduced the Streamable HTTP migration).
protocolVersion20251125 = "2025-11-25"
// protocolVersion20241105 is the legacy protocol version, retained for
// servers that have not yet migrated.
protocolVersion20241105 = "2024-11-05"
// latestProtocolVersion is what the client requests first during initialize.
latestProtocolVersion = protocolVersion20251125
// codeUnsupportedProtocolVersion is the JSON-RPC error code a server returns
// during initialize when it does not support the requested protocol version
// and wishes to advertise the versions it does support.
codeUnsupportedProtocolVersion = -32022
// codeMethodNotFound is the JSON-RPC error code a server returns when it does
// not implement a requested method (e.g. a keepalive ping).
codeMethodNotFound = -32601
// maxInitializeAttempts bounds the initialize version-negotiation retries
// (the first attempt plus one downgrade).
maxInitializeAttempts = 2
// notificationToolsListChanged is the server→client notification signalling
// that the tool list has changed.
notificationToolsListChanged = "notifications/tools/list_changed"
// keepAliveFailureThreshold is the number of consecutive ping failures the
// keepalive loop tolerates before closing the connection for a rebuild.
keepAliveFailureThreshold = 3
)
// supportedProtocolVersions lists every protocol version this client understands,
// newest first. Version negotiation picks the first entry that the server also
// supports.
var supportedProtocolVersions = []string{protocolVersion20251125, protocolVersion20241105}
// isSupportedVersion reports whether v is a protocol version this client speaks.
func isSupportedVersion(v string) bool {
return slices.Contains(supportedProtocolVersions, v)
}
// negotiateMutuallySupportedVersion returns the highest protocol version that is
// supported by both this client and the server (which supports supported). It
// returns "" when there is no overlap.
func negotiateMutuallySupportedVersion(supported []string) string {
for _, v := range supportedProtocolVersions {
if slices.Contains(supported, v) {
return v
}
}
return ""
}
// highestCommonVersion returns the highest protocol version this client supports
// that is not newer than v (used to downgrade when the server picks a version the
// client cannot speak). It returns "" when no acceptable version exists.
func highestCommonVersion(v string) string {
for _, s := range supportedProtocolVersions {
if s <= v {
return s
}
}
return ""
}
// Sentinel errors returned by this package. Test with errors.Is.
var (
// ErrNotConnected is returned when an operation requires an established
// connection but the client is not yet connected or is closed.
ErrNotConnected = errors.New("mcp: not connected")
// ErrClosed is returned when operating on a client or transport that has
// been closed.
ErrClosed = errors.New("mcp: closed")
// ErrNoTransport is returned when the configuration selects no transport
// (neither Command nor URL is set).
ErrNoTransport = errors.New("mcp: no transport configured")
// ErrRPC is returned when the MCP server replies with a JSON-RPC error
// object. The concrete server error is wrapped and accessible via
// errors.Is/errors.As on an *rpcError.
ErrRPC = errors.New("mcp: rpc error")
// ErrUnsupportedProtocolVersion is returned when the client and server
// cannot agree on a mutually supported protocol version after the
// negotiation retries are exhausted.
ErrUnsupportedProtocolVersion = errors.New("mcp: unsupported protocol version")
)
// MCPConfig describes a single MCP server connection. The transport is selected
// by TransportType when set, otherwise inferred from the fields: stdio when
// Command is set, SSE when URL is set. A blank TransportType keeps that legacy
// inference so existing configuration keeps working unchanged.
type MCPConfig struct {
// Name is the human-readable label of the server, used in logs and as the
// prefix for registering its tools uniquely.
Name string `env:"MCP_NAME"`
// Command is the executable path to spawn for a stdio MCP server.
Command string `env:"MCP_COMMAND"`
// Args are the arguments passed to Command.
Args []string `env:"MCP_ARGS"`
// URL is the endpoint of an MCP server (SSE stream endpoint, or the
// Streamable HTTP endpoint when TransportType is "http").
URL string `env:"MCP_URL"`
// TransportType selects the transport explicitly: "stdio", "sse" or
// "http" (Streamable HTTP). Empty means infer from Command/URL.
TransportType string `env:"MCP_TRANSPORT"`
// APIKey is the bearer credential sent to an http/sse server as an
// Authorization header. Empty means the server needs no auth.
APIKey string `env:"MCP_API_KEY"`
// Env is extra environment for a stdio subprocess, merged over the parent
// process environment (extra keys win). It is ignored by the HTTP transports.
Env map[string]string `env:"MCP_ENV"`
// Enabled reports whether this server should be activated at startup.
Enabled bool `env:"MCP_ENABLED"`
}
// Config holds the set of configured MCP servers.
type Config struct {
// Servers is the ordered list of MCP server configurations.
Servers []MCPConfig
}
// ServerInfo describes the MCP server reported by the initialize handshake.
type ServerInfo struct {
// Name is the server-provided name.
Name string
// Version is the server-provided version string.
Version string
}
// ToolInfo is a tool exposed by an MCP server.
type ToolInfo struct {
// Name is the tool identifier used to invoke it.
Name string
// Description explains what the tool does.
Description string
// InputSchema is the tool's JSON Schema, typically
// {"type":"object","properties":{...}}.
InputSchema map[string]any
}
// ContentBlock is a single item in a tool call result (MCP content block).
type ContentBlock struct {
// Type is the block type, most commonly "text".
Type string `json:"type"`
// Text is the textual payload.
Text string `json:"text"`
}
// ToolResult is the structured result of a tools/call.
type ToolResult struct {
// Content holds the response content blocks.
Content []ContentBlock
// IsError reports whether the server flagged the call as an error.
IsError bool
}
// Client is a minimal MCP client bound to a single server described by cfg. It
// is safe for concurrent use; all calls are serialized internally.
type Client struct {
cfg MCPConfig
log *slog.Logger
tr Transport
mu sync.Mutex
state state
info ServerInfo
// negotiatedVersion is the protocol version agreed during initialize; it is
// sent back to the server in the Mcp-Protocol-Version header by the HTTP
// transport.
negotiatedVersion string
// preferredProtocolVersion, when non-empty, overrides latestProtocolVersion
// as the version requested first during initialize. Tests use it to drive the
// negotiation path deterministically.
preferredProtocolVersion string
// keepAlive is the ping interval for the connection keepalive; zero disables
// it. keepAliveFailures is the tolerated consecutive-failure count.
keepAlive time.Duration
keepAliveFailures int
keepaliveCancel context.CancelFunc
// onToolListChanged is invoked (in a fresh goroutine) when the server sends
// notifications/tools/list_changed, so the host can refresh its tool set.
onToolListChanged func()
refreshPending atomic.Bool
}
// protocolVersion returns the protocol version negotiated by initialize, or ""
// before a successful handshake.
func (c *Client) protocolVersion() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.negotiatedVersion
}
// setProtocolVersion records the version negotiated by initialize.
func (c *Client) setProtocolVersion(v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.negotiatedVersion = v
}
// capabilities returns the client capability declaration for the given protocol
// version. It advertises tools.listChanged so the server notifies the client when
// the tool list changes.
func (c *Client) capabilities(version string) map[string]any {
caps := map[string]any{}
if c.onToolListChanged != nil {
caps["tools"] = map[string]any{"listChanged": true}
}
return caps
}
// state tracks the client lifecycle.
type state int
const (
stateNew state = iota
stateConnected
stateClosed
)
// Transport is the JSON-RPC message exchange with an MCP server over a concrete
// transport (stdio subprocess, SSE or Streamable HTTP). Implementations must be
// safe for concurrent use. This is exported so tests and hosts can inject a fake
// transport.
type Transport interface {
// Start makes the transport ready to exchange messages.
Start(ctx context.Context) error
// Call sends a JSON-RPC request and decodes the matching response into
// result. Params is marshaled into the request body; result is unmarshaled
// from the response's result field.
Call(ctx context.Context, method string, params any, result any) error
// Close releases every underlying resource (subprocess, streams, sockets).
Close() error
}
// notifier is implemented by transports that can send a JSON-RPC notification
// (a request with no id and no response).
type notifier interface {
notify(ctx context.Context, method string, params any) error
}
// serverNotifier is implemented by transports that can deliver server-initiated
// notifications (messages with no id, e.g. notifications/tools/list_changed) back
// to the client. The handler is invoked from the transport's read path, so it must
// not block or re-enter the same transport synchronously.
type serverNotifier interface {
setOnNotification(fn func(method string))
}
// attachNotificationSink wires a server→client notification handler into tr, if
// tr supports one.
func attachNotificationSink(tr Transport, fn func(method string)) {
if on, ok := tr.(serverNotifier); ok {
on.setOnNotification(fn)
}
}
// protocolProvider is implemented by transports that include the negotiated MCP
// protocol version in requests (the Mcp-Protocol-Version header of the HTTP
// transport).
type protocolProvider interface {
setProtocolProvider(fn func() string)
}
// attachProtocolProvider wires the negotiated-version getter into tr, if tr
// supports one.
func attachProtocolProvider(tr Transport, fn func() string) {
if p, ok := tr.(protocolProvider); ok {
p.setProtocolProvider(fn)
}
}
// Option customizes a Client built by New.
type Option func(*Client)
// WithLogger overrides the client's logger. The given logger is tagged with the
// source capability/mcp.
func WithLogger(l *slog.Logger) Option {
return func(c *Client) {
if l != nil {
c.log = logger.WithSource(l, "capability/mcp")
}
}
}
// WithTransport overrides the transport used by the client. This is primarily
// for tests that inject a fake transport instead of spawning a subprocess or
// dialing SSE.
func WithTransport(t Transport) Option {
return func(c *Client) {
c.tr = t
}
}
// WithKeepAlive enables the connection keepalive, sending an MCP ping every
// interval and closing the session after keepAliveFailures consecutive missed
// pings. A zero interval leaves keepalive disabled. When keepAliveFailures is
// omitted, the package default (keepAliveFailureThreshold) is used.
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 registers a handler that is invoked (in a fresh
// goroutine) whenever the server sends notifications/tools/list_changed, letting
// the host refresh its registered tool set.
func WithToolListChangedHandler(fn func()) Option {
return func(c *Client) {
c.onToolListChanged = fn
}
}
// WithProtocolVersion overrides the protocol version requested first during
// initialize. It is primarily for tests that drive the negotiation path
// deterministically; callers should normally leave it unset to use
// latestProtocolVersion.
func WithProtocolVersion(v string) Option {
return func(c *Client) {
c.preferredProtocolVersion = v
}
}
// New builds a Client for cfg. By default it uses a discard logger and selects a
// stdio or SSE transport from cfg; pass WithLogger and/or WithTransport to
// override.
func New(cfg MCPConfig, opts ...Option) *Client {
c := &Client{
cfg: cfg,
log: defaultLogger(),
state: stateNew,
}
for _, opt := range opts {
opt(c)
}
return c
}
// defaultLogger returns a discard logger tagged with the mcp source. Hosts
// should prefer WithLogger so records are observable.
func defaultLogger() *slog.Logger {
base := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn}))
return logger.WithSource(base, "capability/mcp")
}
// transport returns the established transport, or nil when not connected.
func (c *Client) transport() Transport {
c.mu.Lock()
defer c.mu.Unlock()
if c.state != stateConnected || c.tr == nil {
return nil
}
return c.tr
}