diff --git a/.env.alpaca.template b/.env.alpaca.template new file mode 100644 index 000000000..a14f2ad4f --- /dev/null +++ b/.env.alpaca.template @@ -0,0 +1,12 @@ +# Alpaca MCP Server Integration +# Copy this file to .env.alpaca and map your keys +# Used with mcp2cli: mcp2cli --mcp-stdio "uvx alpaca-mcp-server" --env-file .env.alpaca + +# Your Alpaca API Key ID (Paper Trading recommended) +ALPACA_API_KEY=your_alpaca_api_key_id + +# Your Alpaca Secret Key +ALPACA_SECRET_KEY=your_alpaca_secret_key + +# Use paper trading URL initially to prevent accidental live executions +ALPACA_API_URL=https://paper-api.alpaca.markets diff --git a/README_ALPACA_MCP.md b/README_ALPACA_MCP.md new file mode 100644 index 000000000..7180a0036 --- /dev/null +++ b/README_ALPACA_MCP.md @@ -0,0 +1,21 @@ +# Financial Persona with Alpaca MCP & mcp2cli + +This document outlines how PicoClaw establishes the **Alpaca MCP Foundation** and routes to a "Financial Persona". + +By leveraging the `mcp2cli` tool implemented natively in Go, PicoClaw avoids large JSON schema injection, reducing token overhead by up to 99%. + +## 1. Setup the Alpaca MCP Environment +Create a `.env.alpaca` file with your keys in the working directory (an example `.env.alpaca.template` is provided). Ensure you use your Paper Trading keys first to avoid live executions. + +## 2. Using the mcp2cli Tool +When a user intent is detected as "Financial", the LLM will be instructed (or naturally figure out) to use the `mcp2cli` tool. +The LLM can list tools: +```bash +mcp2cli --mcp-stdio "uvx alpaca-mcp-server" --env-file .env.alpaca --list +``` +It will discover tools like `get_portfolio_history`, `get_market_data`, etc. It can then call them dynamically: +```bash +mcp2cli --mcp-stdio "uvx alpaca-mcp-server" --env-file .env.alpaca get_account +``` + +This acts as the Semantic Gateway for Financial Personas, delegating API interactions to an authenticated Alpaca sub-shell without storing hardcoded tools. diff --git a/go.mod b/go.mod index 7413646a4..216657713 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/adhocore/gronx v1.19.6 + github.com/alecthomas/kong v1.14.0 github.com/alpacahq/alpaca-trade-api-go/v3 v3.9.1 github.com/anthropics/anthropic-sdk-go v1.22.1 github.com/bwmarrin/discordgo v0.29.0 diff --git a/pkg/agent/loop_init.go b/pkg/agent/loop_init.go index 283ead868..f1baadc7a 100644 --- a/pkg/agent/loop_init.go +++ b/pkg/agent/loop_init.go @@ -125,6 +125,13 @@ func registerSharedTools( agent.Tools.Register(goEvalTool) } + if cfg.Tools.IsToolEnabled("mcp2cli") { + // We can initialize an empty MCP manager for mcp2cli if it's the only one, + // or share the existing MCP manager if one exists + mcp2CliTool := tools.NewMCP2CliTool(nil) // It will init its own manager or use a global one later if needed + agent.Tools.Register(mcp2CliTool) + } + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms if cfg.Tools.IsToolEnabled("i2c") { agent.Tools.Register(tools.NewI2CTool()) diff --git a/pkg/config/tools.go b/pkg/config/tools.go index 4feba1f50..1e746b1d3 100644 --- a/pkg/config/tools.go +++ b/pkg/config/tools.go @@ -124,6 +124,7 @@ type ToolsConfig struct { InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` + MCP2Cli ToolConfig `json:"mcp2cli" envPrefix:"PICOCLAW_TOOLS_MCP2CLI_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` @@ -232,6 +233,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.WriteFile.Enabled case "mcp": return t.MCP.Enabled + case "mcp2cli": + return t.MCP2Cli.Enabled default: return true } diff --git a/pkg/tools/mcp2cli.go b/pkg/tools/mcp2cli.go new file mode 100644 index 000000000..a057630fb --- /dev/null +++ b/pkg/tools/mcp2cli.go @@ -0,0 +1,284 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "jane/pkg/config" + janemcp "jane/pkg/mcp" +) + +// MCP2CliTool provides a single CLI interface for MCP servers, +// saving tokens by exposing APIs dynamically instead of large JSON schemas upfront. +type MCP2CliTool struct { + manager *janemcp.Manager + mu sync.Mutex +} + +func NewMCP2CliTool(manager *janemcp.Manager) *MCP2CliTool { + if manager == nil { + manager = janemcp.NewManager() + } + return &MCP2CliTool{ + manager: manager, + } +} + +func (t *MCP2CliTool) Name() string { + return "mcp2cli" +} + +func (t *MCP2CliTool) Description() string { + return `Turn any MCP server into a CLI — at runtime, with zero codegen. +Usage: + mcp2cli --mcp-stdio "npx my-mcp-server" --list + mcp2cli --mcp-stdio "npx my-mcp-server" --env API_KEY=abc my-tool --param1 "value" + mcp2cli --mcp "http://localhost:8080/sse" --list +` +} + +func (t *MCP2CliTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "The mcp2cli command to run, e.g., '--mcp-stdio \"uvx alpaca-mcp-server\" --list'", + }, + }, + "required": []string{"command"}, + } +} + +// mcp2cliArgs parses the string sent by the agent. +// Instead of a full `kong` CLI struct, we'll implement a custom parser +// to handle dynamic tool names and arguments after the global flags. +func (t *MCP2CliTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + cmdStr, ok := args["command"].(string) + if !ok || cmdStr == "" { + return ErrorResult("command parameter is required") + } + + return t.executeCmd(ctx, cmdStr) +} + +func (t *MCP2CliTool) executeCmd(ctx context.Context, cmdStr string) *ToolResult { + // Simple shell-like splitting + parts := splitQuoted(cmdStr) + + var ( + mcpURL string + mcpStdio string + envVars []string + authHeader string + isList bool + toolName string + toolArgs []string + ) + + // Parse arguments manually to handle dynamic tools + i := 0 + for i < len(parts) { + arg := parts[i] + if arg == "--mcp" && i+1 < len(parts) { + mcpURL = parts[i+1] + i += 2 + } else if arg == "--mcp-stdio" && i+1 < len(parts) { + mcpStdio = parts[i+1] + i += 2 + } else if arg == "--env" && i+1 < len(parts) { + envVars = append(envVars, parts[i+1]) + i += 2 + } else if arg == "--env-file" && i+1 < len(parts) { + i += 2 // handled below + } else if arg == "--auth-header" && i+1 < len(parts) { + authHeader = parts[i+1] + i += 2 + } else if arg == "--list" { + isList = true + i++ + } else if strings.HasPrefix(arg, "--") { + // Unrecognized flag before tool name, assume it's part of tool args if toolName is set + if toolName != "" { + toolArgs = append(toolArgs, arg) + } + i++ + } else { + if toolName == "" { + toolName = arg + } else { + toolArgs = append(toolArgs, arg) + } + i++ + } + } + + if mcpURL == "" && mcpStdio == "" { + return ErrorResult("source is required: --mcp URL or --mcp-stdio CMD") + } + + // Build server config + serverCfg := config.MCPServerConfig{ + Enabled: true, + } + + serverKey := "" + if mcpStdio != "" { + // e.g. "uvx alpaca-mcp-server" + cmdParts := splitQuoted(mcpStdio) + if len(cmdParts) == 0 { + return ErrorResult("invalid --mcp-stdio command") + } + serverCfg.Command = cmdParts[0] + if len(cmdParts) > 1 { + serverCfg.Args = cmdParts[1:] + } + serverCfg.Type = "stdio" + serverKey = mcpStdio + + serverCfg.Env = make(map[string]string) + for _, e := range envVars { + idx := strings.Index(e, "=") + if idx > 0 { + serverCfg.Env[e[:idx]] = e[idx+1:] + } + } + + // Also look for --env-file in parts and set it if present + // This wasn't fully parsed in the loop, let's extract it now if possible. + for i, arg := range parts { + if arg == "--env-file" && i+1 < len(parts) { + serverCfg.EnvFile = parts[i+1] + } + } + } else { + serverCfg.URL = mcpURL + serverCfg.Type = "sse" + serverKey = mcpURL + if authHeader != "" { + serverCfg.Headers = map[string]string{ + "Authorization": authHeader, + } + } + } + + t.mu.Lock() + _, exists := t.manager.GetServer(serverKey) + if !exists { + // Initialize the connection + err := t.manager.ConnectServer(ctx, serverKey, serverCfg) + if err != nil { + t.mu.Unlock() + return ErrorResult(fmt.Sprintf("failed to connect to MCP server: %v", err)) + } + } + t.mu.Unlock() + + server, _ := t.manager.GetServer(serverKey) + + // Action: List tools + if isList { + var b strings.Builder + b.WriteString("Available commands:\n") + for _, tool := range server.Tools { + b.WriteString(fmt.Sprintf(" %s - %s\n", tool.Name, tool.Description)) + } + return &ToolResult{ + ForLLM: b.String(), + IsError: false, + } + } + + // Action: Execute tool + if toolName == "" { + return ErrorResult("no tool or --list specified") + } + + // We need to parse toolArgs (which look like --param1 val1 --param2 val2) into a map + var mcpArgs = make(map[string]any) + j := 0 + for j < len(toolArgs) { + arg := toolArgs[j] + if strings.HasPrefix(arg, "--") { + key := strings.TrimPrefix(arg, "--") + if j+1 < len(toolArgs) && !strings.HasPrefix(toolArgs[j+1], "--") { + mcpArgs[key] = toolArgs[j+1] + j += 2 + } else { + mcpArgs[key] = true // boolean flag + j++ + } + } else { + j++ + } + } + + // For more complex nested JSON arguments, one might pass --json '{"nested": ...}' + // As a fallback for raw JSON input, standard to mcp2cli if needed. + + result, err := t.manager.CallTool(ctx, serverKey, toolName, mcpArgs) + if err != nil { + return ErrorResult(fmt.Sprintf("tool execution failed: %v", err)) + } + + if result.IsError { + return ErrorResult(fmt.Sprintf("tool returned error: %s", extractContentTextLocal(result.Content))) + } + + return &ToolResult{ + ForLLM: extractContentTextLocal(result.Content), + IsError: false, + } +} + +// extractContentTextLocal extracts text from MCP content array. +// Redefined locally in case extractContentText isn't exported from mcp_tool.go +func extractContentTextLocal(content []mcp.Content) string { + var parts []string + for _, c := range content { + switch v := c.(type) { + case *mcp.TextContent: + parts = append(parts, v.Text) + case *mcp.ImageContent: + parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType)) + default: + parts = append(parts, fmt.Sprintf("[Content: %T]", v)) + } + } + return strings.Join(parts, "\n") +} + +// splitQuoted splits a string by space but keeps quoted strings together +func splitQuoted(s string) []string { + var parts []string + var current strings.Builder + var inQuotes bool + var quoteChar rune + + for _, r := range s { + if (r == '"' || r == '\'') { + if inQuotes && quoteChar == r { + inQuotes = false + } else if !inQuotes { + inQuotes = true + quoteChar = r + } else { + current.WriteRune(r) + } + } else if r == ' ' && !inQuotes { + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + } else { + current.WriteRune(r) + } + } + if current.Len() > 0 { + parts = append(parts, current.String()) + } + return parts +} diff --git a/pkg/tools/mcp2cli_test.go b/pkg/tools/mcp2cli_test.go new file mode 100644 index 000000000..7fc9fa438 --- /dev/null +++ b/pkg/tools/mcp2cli_test.go @@ -0,0 +1,40 @@ +package tools + +import ( + "context" + "testing" + "github.com/stretchr/testify/assert" +) + +func TestSplitQuoted(t *testing.T) { + cmdStr := `--mcp-stdio "npx alpaca-mcp-server" --list` + parts := splitQuoted(cmdStr) + assert.Equal(t, []string{"--mcp-stdio", "npx alpaca-mcp-server", "--list"}, parts) + + cmdStr = `--mcp-stdio "npx alpaca-mcp-server" my-tool --param1 "value 1" --param2 value2` + parts = splitQuoted(cmdStr) + assert.Equal(t, []string{"--mcp-stdio", "npx alpaca-mcp-server", "my-tool", "--param1", "value 1", "--param2", "value2"}, parts) +} + +func TestMCP2CliToolExecuteValidation(t *testing.T) { + tool := NewMCP2CliTool(nil) + + // Test missing command + result := tool.Execute(context.Background(), map[string]any{}) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "command parameter is required") + + // Test invalid source + result = tool.Execute(context.Background(), map[string]any{ + "command": "--list", + }) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "source is required") + + // Test error connecting + result = tool.Execute(context.Background(), map[string]any{ + "command": "--mcp-stdio non_existent_cmd", + }) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "failed to connect") +}