Files
go-llm/v2/mcp.go
Steve Dudenhoeffer a4cb4baab5 Add go-llm v2: redesigned API for simpler LLM abstraction
v2 is a new Go module (v2/) with a dramatically simpler API:
- Unified Message type (no more Input marker interface)
- Define[T] for ergonomic tool creation with standard context.Context
- Chat session with automatic tool-call loop (agent loop)
- Streaming via pull-based StreamReader
- MCP one-call connect (MCPStdioServer, MCPHTTPServer, MCPSSEServer)
- Middleware support (logging, retry, timeout, usage tracking)
- Decoupled JSON Schema (map[string]any, no provider coupling)
- Sample tools: WebSearch, Browser, Exec, ReadFile, WriteFile, HTTP
- Providers: OpenAI, Anthropic, Google (all with streaming)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:00:08 -05:00

265 lines
5.3 KiB
Go

package llm
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MCPTransport specifies how to connect to an MCP server.
type MCPTransport string
const (
MCPStdio MCPTransport = "stdio"
MCPSSE MCPTransport = "sse"
MCPHTTP MCPTransport = "http"
)
// MCPServer represents a connection to an MCP server.
type MCPServer struct {
name string
transport MCPTransport
// stdio fields
command string
args []string
env []string
// network fields
url string
// internal
client *mcp.Client
session *mcp.ClientSession
tools map[string]*mcp.Tool
mu sync.RWMutex
}
// MCPOption configures an MCP server.
type MCPOption func(*MCPServer)
// WithMCPEnv adds environment variables for the subprocess.
func WithMCPEnv(env ...string) MCPOption {
return func(s *MCPServer) { s.env = env }
}
// WithMCPName sets a friendly name for logging.
func WithMCPName(name string) MCPOption {
return func(s *MCPServer) { s.name = name }
}
// MCPStdioServer creates and connects to an MCP server via stdio transport.
//
// Example:
//
// server, err := llm.MCPStdioServer(ctx, "npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp")
func MCPStdioServer(ctx context.Context, command string, args ...string) (*MCPServer, error) {
s := &MCPServer{
name: command,
transport: MCPStdio,
command: command,
args: args,
}
if err := s.connect(ctx); err != nil {
return nil, err
}
return s, nil
}
// MCPHTTPServer creates and connects to an MCP server via streamable HTTP transport.
//
// Example:
//
// server, err := llm.MCPHTTPServer(ctx, "https://mcp.example.com")
func MCPHTTPServer(ctx context.Context, url string, opts ...MCPOption) (*MCPServer, error) {
s := &MCPServer{
name: url,
transport: MCPHTTP,
url: url,
}
for _, opt := range opts {
opt(s)
}
if err := s.connect(ctx); err != nil {
return nil, err
}
return s, nil
}
// MCPSSEServer creates and connects to an MCP server via SSE transport.
func MCPSSEServer(ctx context.Context, url string, opts ...MCPOption) (*MCPServer, error) {
s := &MCPServer{
name: url,
transport: MCPSSE,
url: url,
}
for _, opt := range opts {
opt(s)
}
if err := s.connect(ctx); err != nil {
return nil, err
}
return s, nil
}
func (s *MCPServer) connect(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.session != nil {
return nil
}
s.client = mcp.NewClient(&mcp.Implementation{
Name: "go-llm-v2",
Version: "2.0.0",
}, nil)
var transport mcp.Transport
switch s.transport {
case MCPSSE:
transport = &mcp.SSEClientTransport{
Endpoint: s.url,
}
case MCPHTTP:
transport = &mcp.StreamableClientTransport{
Endpoint: s.url,
}
default: // stdio
cmd := exec.Command(s.command, s.args...)
cmd.Env = append(os.Environ(), s.env...)
transport = &mcp.CommandTransport{
Command: cmd,
}
}
session, err := s.client.Connect(ctx, transport, nil)
if err != nil {
return fmt.Errorf("failed to connect to MCP server %s: %w", s.name, err)
}
s.session = session
// Load tools
s.tools = make(map[string]*mcp.Tool)
for tool, err := range session.Tools(ctx, nil) {
if err != nil {
s.session.Close()
s.session = nil
return fmt.Errorf("failed to list tools from %s: %w", s.name, err)
}
s.tools[tool.Name] = tool
}
return nil
}
// Close closes the connection to the MCP server.
func (s *MCPServer) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.session == nil {
return nil
}
err := s.session.Close()
s.session = nil
s.tools = nil
return err
}
// IsConnected returns true if the server is connected.
func (s *MCPServer) IsConnected() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.session != nil
}
// ListTools returns Tool definitions for all tools this server provides.
func (s *MCPServer) ListTools() []Tool {
s.mu.RLock()
defer s.mu.RUnlock()
var tools []Tool
for _, t := range s.tools {
tools = append(tools, s.toTool(t))
}
return tools
}
// CallTool invokes a tool on the server.
func (s *MCPServer) CallTool(ctx context.Context, name string, arguments map[string]any) (string, error) {
s.mu.RLock()
session := s.session
s.mu.RUnlock()
if session == nil {
return "", fmt.Errorf("%w: %s", ErrNotConnected, s.name)
}
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: name,
Arguments: arguments,
})
if err != nil {
return "", err
}
if len(result.Content) == 0 {
return "", nil
}
return contentToString(result.Content), nil
}
func (s *MCPServer) toTool(t *mcp.Tool) Tool {
var inputSchema map[string]any
if t.InputSchema != nil {
data, err := json.Marshal(t.InputSchema)
if err == nil {
_ = json.Unmarshal(data, &inputSchema)
}
}
if inputSchema == nil {
inputSchema = map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
return Tool{
Name: t.Name,
Description: t.Description,
Schema: inputSchema,
isMCP: true,
mcpServer: s,
}
}
func contentToString(content []mcp.Content) string {
var parts []string
for _, c := range content {
switch tc := c.(type) {
case *mcp.TextContent:
parts = append(parts, tc.Text)
default:
if data, err := json.Marshal(c); err == nil {
parts = append(parts, string(data))
}
}
}
if len(parts) == 1 {
return parts[0]
}
data, _ := json.Marshal(parts)
return string(data)
}