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 (
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
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