feat(tools): add mcp2cli tool with Alpaca financial persona template
Implemented a native Go `mcp2cli` tool to avoid passing large MCP JSON schemas to the LLM context upfront. Added support for dynamically routing to MCP servers via CLI-like commands. Provided `.env.alpaca.template` and documentation (`README_ALPACA_MCP.md`) to establish the "Financial Persona" using the Alpaca MCP server. Registered the `mcp2cli` tool into PicoClaw configurations. Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
This commit is contained in:
parent
dbd89a41cb
commit
ea8a61225c
7 changed files with 368 additions and 0 deletions
12
.env.alpaca.template
Normal file
12
.env.alpaca.template
Normal file
|
|
@ -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
|
||||||
21
README_ALPACA_MCP.md
Normal file
21
README_ALPACA_MCP.md
Normal file
|
|
@ -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.
|
||||||
1
go.mod
1
go.mod
|
|
@ -4,6 +4,7 @@ go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/adhocore/gronx v1.19.6
|
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/alpacahq/alpaca-trade-api-go/v3 v3.9.1
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1
|
github.com/anthropics/anthropic-sdk-go v1.22.1
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,13 @@ func registerSharedTools(
|
||||||
agent.Tools.Register(goEvalTool)
|
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
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
if cfg.Tools.IsToolEnabled("i2c") {
|
if cfg.Tools.IsToolEnabled("i2c") {
|
||||||
agent.Tools.Register(tools.NewI2CTool())
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ type ToolsConfig struct {
|
||||||
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
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_"`
|
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
|
|
@ -232,6 +233,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return t.WriteFile.Enabled
|
return t.WriteFile.Enabled
|
||||||
case "mcp":
|
case "mcp":
|
||||||
return t.MCP.Enabled
|
return t.MCP.Enabled
|
||||||
|
case "mcp2cli":
|
||||||
|
return t.MCP2Cli.Enabled
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
284
pkg/tools/mcp2cli.go
Normal file
284
pkg/tools/mcp2cli.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
40
pkg/tools/mcp2cli_test.go
Normal file
40
pkg/tools/mcp2cli_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue