210 lines
6.7 KiB
Go
210 lines
6.7 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) or SSE, 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).
|
|
//
|
|
// 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"
|
|
"sync"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
// protocolVersion is the MCP protocol version this client advertises during the
|
|
// initialize handshake.
|
|
const protocolVersion = "2024-11-05"
|
|
|
|
// 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")
|
|
)
|
|
|
|
// MCPConfig describes a single MCP server connection. Exactly one transport is
|
|
// selected: stdio when Command is set, SSE when URL is set.
|
|
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 SSE endpoint of an MCP server.
|
|
URL string `env:"MCP_URL"`
|
|
// 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
|
|
}
|
|
|
|
// 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 or SSE). 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|