286 lines
7.5 KiB
Go
286 lines
7.5 KiB
Go
package sidekick
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/client"
|
|
"github.com/mark3labs/mcp-go/client/transport"
|
|
"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)
|
|
ListTools(ctx context.Context) ([]ToolDefinition, error)
|
|
Close() error
|
|
}
|
|
|
|
type mcpGoClientWrapper struct {
|
|
client *client.Client
|
|
}
|
|
|
|
func (w *mcpGoClientWrapper) CallTool(ctx context.Context, name string,
|
|
args map[string]interface{}) (string, error) {
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Name = name
|
|
req.Params.Arguments = args
|
|
|
|
res, err := w.client.CallTool(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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, ¶ms)
|
|
}
|
|
|
|
tools = append(tools, ToolDefinition{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: params,
|
|
Internal: false,
|
|
})
|
|
}
|
|
return tools, nil
|
|
}
|
|
|
|
func (w *mcpGoClientWrapper) Close() error {
|
|
if w.client != nil {
|
|
return w.client.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Global server configurations
|
|
var mcpServers map[string]MCPServerConfig
|
|
|
|
// GlobalLogger is an optional logger for MCP connections
|
|
var GlobalLogger func(format string, v ...interface{})
|
|
|
|
// InitMCPServers sets up the global registry for MCP factories
|
|
func InitMCPServers(servers map[string]MCPServerConfig) {
|
|
mcpServers = servers
|
|
}
|
|
|
|
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...)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
if GlobalLogger != nil {
|
|
GlobalLogger("Initializing MCP connection to %s via %s", toolset, cfg.Transport)
|
|
}
|
|
|
|
var c *client.Client
|
|
var err error
|
|
|
|
switch cfg.Transport {
|
|
case "stdio":
|
|
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()
|
|
}
|
|
|
|
// Use WithCommandFunc and redirect Stderr directly to os.Stderr
|
|
// This provides the most reliable way to see subprocess errors
|
|
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
|
|
cmd.Stderr = os.Stderr
|
|
return cmd, nil
|
|
}),
|
|
)
|
|
case "sse":
|
|
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...)
|
|
case "http":
|
|
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...)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported MCP transport: %s", cfg.Transport)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
|
|
initCtx, initCancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer initCancel()
|
|
_, err = c.Initialize(initCtx, initReq)
|
|
if err != nil {
|
|
_ = c.Close()
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// CallExternalTool creates a scoped MCP connection, calls the tool, and cleans up
|
|
func CallExternalTool(ctx context.Context, toolset string, toolName string,
|
|
args map[string]interface{}) (string, error) {
|
|
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)
|
|
}
|