Files
Sidekick/mcp.go
T

292 lines
7.6 KiB
Go
Raw Normal View History

2026-03-21 16:20:16 +02:00
package sidekick
import (
"context"
2026-03-22 13:16:39 +02:00
"encoding/json"
2026-03-21 16:20:16 +02:00
"fmt"
2026-03-22 13:32:41 +02:00
"io"
2026-03-22 13:16:39 +02:00
"os"
"os/exec"
"strings"
"time"
2026-03-21 16:20:16 +02:00
"github.com/mark3labs/mcp-go/client"
2026-03-21 16:29:28 +02:00
"github.com/mark3labs/mcp-go/client/transport"
2026-03-21 16:20:16 +02:00
"github.com/mark3labs/mcp-go/mcp"
)
// MCPClientInterface represents a scoped connection to an MCP server
type MCPClientInterface interface {
CallTool(ctx context.Context, name string, args map[string]interface{}) (string, error)
2026-03-22 13:16:39 +02:00
ListTools(ctx context.Context) ([]ToolDefinition, error)
2026-03-21 16:20:16 +02:00
Close() error
}
type mcpGoClientWrapper struct {
client *client.Client
}
2026-03-22 12:01:29 +02:00
func (w *mcpGoClientWrapper) CallTool(ctx context.Context, name string,
args map[string]interface{}) (string, error) {
2026-03-21 16:20:16 +02:00
req := mcp.CallToolRequest{}
req.Params.Name = name
req.Params.Arguments = args
2026-03-21 16:29:28 +02:00
2026-03-21 16:20:16 +02:00
res, err := w.client.CallTool(ctx, req)
if err != nil {
return "", err
}
2026-03-21 16:29:28 +02:00
2026-03-21 16:20:16 +02:00
if res.IsError {
return "", fmt.Errorf("tool returned error")
}
// Extract text from result content
var output string
for _, c := range res.Content {
if textContent, ok := c.(mcp.TextContent); ok {
output += textContent.Text + "\n"
} else {
output += fmt.Sprintf("%v\n", c)
}
}
return output, nil
}
2026-03-22 13:16:39 +02:00
func (w *mcpGoClientWrapper) ListTools(ctx context.Context) ([]ToolDefinition, error) {
req := mcp.ListToolsRequest{}
res, err := w.client.ListTools(ctx, req)
if err != nil {
return nil, err
}
var tools []ToolDefinition
for _, t := range res.Tools {
var params map[string]interface{}
b, err := json.Marshal(t.InputSchema)
if err == nil {
_ = json.Unmarshal(b, &params)
}
tools = append(tools, ToolDefinition{
Name: t.Name,
Description: t.Description,
Parameters: params,
Internal: false,
})
}
return tools, nil
}
2026-03-21 16:20:16 +02:00
func (w *mcpGoClientWrapper) Close() error {
2026-03-22 13:16:39 +02:00
if w.client != nil {
return w.client.Close()
}
2026-03-21 16:20:16 +02:00
return nil
}
// Global server configurations
var mcpServers map[string]MCPServerConfig
2026-03-22 12:46:02 +02:00
// GlobalLogger is an optional logger for MCP connections
var GlobalLogger func(format string, v ...interface{})
2026-03-21 16:20:16 +02:00
// InitMCPServers sets up the global registry for MCP factories
func InitMCPServers(servers map[string]MCPServerConfig) {
mcpServers = servers
}
2026-03-22 13:16:39 +02:00
type sidekickLogger struct{}
func (l *sidekickLogger) Infof(format string, v ...any) {
if GlobalLogger != nil {
GlobalLogger(format, v...)
}
}
func (l *sidekickLogger) Errorf(format string, v ...any) {
if GlobalLogger != nil {
GlobalLogger("ERROR: "+format, v...)
}
}
2026-03-21 16:20:16 +02:00
// defaultMCPFactory creates a real MCP client based on the global configuration
var defaultMCPFactory MCPClientFactory = func(toolset string) (MCPClientInterface, error) {
cfg, ok := mcpServers[toolset]
if !ok {
return nil, fmt.Errorf("unknown MCP server: %s", toolset)
}
2026-03-22 12:46:02 +02:00
if GlobalLogger != nil {
GlobalLogger("Initializing MCP connection to %s via %s", toolset, cfg.Transport)
}
2026-03-21 16:20:16 +02:00
var c *client.Client
var err error
switch cfg.Transport {
case "stdio":
2026-03-22 13:16:39 +02:00
if GlobalLogger != nil {
GlobalLogger("Running stdio MCP: %s %v (env: %d vars)", cfg.Command, cfg.Args, len(cfg.Env))
}
command := cfg.Command
args := cfg.Args
// If using npx, try to be quiet to avoid stdout pollution
if command == "npx" {
quietFound := false
for _, arg := range args {
if arg == "--quiet" || arg == "-q" {
quietFound = true
break
}
}
if !quietFound {
// Prepend --quiet to args
args = append([]string{"--quiet"}, args...)
}
}
env := cfg.Env
if len(env) > 0 {
foundPath := false
for _, e := range env {
if strings.HasPrefix(strings.ToUpper(e), "PATH=") {
foundPath = true
break
}
}
if !foundPath {
if path := os.Getenv("PATH"); path != "" {
env = append(env, "PATH="+path)
}
}
} else {
env = os.Environ()
}
2026-03-22 13:32:41 +02:00
// Use WithCommandFunc to configure the command environment
2026-03-22 13:16:39 +02:00
c, err = client.NewStdioMCPClientWithOptions(command, env, args,
transport.WithCommandFunc(func(ctx context.Context, command string, env []string, args []string) (*exec.Cmd, error) {
cmd := exec.CommandContext(ctx, command, args...)
cmd.Env = env
return cmd, nil
}),
)
2026-03-21 16:20:16 +02:00
case "sse":
2026-03-21 16:29:28 +02:00
var opts []transport.ClientOption
headers := make(map[string]string)
if cfg.AuthToken != "" {
headers["Authorization"] = "Bearer " + cfg.AuthToken
}
for k, v := range cfg.Headers {
headers[k] = v
}
if len(headers) > 0 {
opts = append(opts, transport.WithHeaders(headers))
}
c, err = client.NewSSEMCPClient(cfg.URL, opts...)
2026-03-21 16:20:16 +02:00
case "http":
2026-03-21 16:29:28 +02:00
var opts []transport.StreamableHTTPCOption
headers := make(map[string]string)
if cfg.AuthToken != "" {
headers["Authorization"] = "Bearer " + cfg.AuthToken
}
for k, v := range cfg.Headers {
headers[k] = v
}
if len(headers) > 0 {
opts = append(opts, transport.WithHTTPHeaders(headers))
}
c, err = client.NewStreamableHttpClient(cfg.URL, opts...)
2026-03-21 16:20:16 +02:00
default:
return nil, fmt.Errorf("unsupported MCP transport: %s", cfg.Transport)
}
if err != nil {
return nil, err
}
2026-03-22 13:32:41 +02:00
// Redirect stderr from the stdio transport to os.Stderr
if cfg.Transport == "stdio" {
if stderr, ok := client.GetStderr(c); ok {
go io.Copy(os.Stderr, stderr)
}
}
2026-03-21 16:20:16 +02:00
// Initialize the client session
initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcp.Implementation{
Name: "Sidekick",
Version: "1.0.0",
}
2026-03-21 16:29:28 +02:00
2026-03-22 13:16:39 +02:00
initCtx, initCancel := context.WithTimeout(context.Background(), 20*time.Second)
defer initCancel()
_, err = c.Initialize(initCtx, initReq)
2026-03-21 16:20:16 +02:00
if err != nil {
2026-03-22 13:16:39 +02:00
_ = c.Close()
2026-03-21 16:20:16 +02:00
return nil, fmt.Errorf("failed to initialize MCP client: %w", err)
}
return &mcpGoClientWrapper{client: c}, nil
}
// MCPClientFactory is a function that creates a new MCP client for a specific toolset
type MCPClientFactory func(toolset string) (MCPClientInterface, error)
// SetMCPClientFactory allows overriding the factory for testing
func SetMCPClientFactory(factory MCPClientFactory) {
defaultMCPFactory = factory
}
2026-03-22 13:16:39 +02:00
// LoadExternalTools connects to all registered MCP servers and fetches their tools
func LoadExternalTools(ctx context.Context) []ToolDefinition {
var extTools []ToolDefinition
for toolset := range mcpServers {
c, err := defaultMCPFactory(toolset)
if err != nil {
if GlobalLogger != nil {
GlobalLogger("Failed to connect to MCP server %s: %v", toolset, err)
}
continue
}
listCtx, listCancel := context.WithTimeout(ctx, 10*time.Second)
tools, err := c.ListTools(listCtx)
listCancel()
c.Close()
if err != nil {
if GlobalLogger != nil {
GlobalLogger("Failed to list tools from MCP server %s: %v", toolset, err)
}
continue
}
// Assign toolset to each tool
for i := range tools {
tools[i].Toolset = toolset
}
if GlobalLogger != nil {
GlobalLogger("Loaded %d tools from MCP server %s", len(tools), toolset)
}
extTools = append(extTools, tools...)
}
return extTools
}
2026-03-21 16:20:16 +02:00
// CallExternalTool creates a scoped MCP connection, calls the tool, and cleans up
2026-03-22 12:01:29 +02:00
func CallExternalTool(ctx context.Context, toolset string, toolName string,
args map[string]interface{}) (string, error) {
2026-03-21 16:20:16 +02:00
c, err := defaultMCPFactory(toolset)
if err != nil {
return "", fmt.Errorf("failed to create MCP client for %s: %w", toolset, err)
}
defer c.Close()
return c.CallTool(ctx, toolName, args)
}