Skip to content

Commit 84329b6

Browse files
committed
feat: add dynamic command generation from MCP tools/list
Auto-generate CLI commands from cached MCP tool definitions so every Pipeboard tool is available as `pipeboard <platform> <command> --flags`. - `pipeboard refresh` fetches tools from all MCP servers and caches to ~/.pipeboard/tools-cache.json - On startup, loads cache and registers Cobra subcommands per platform (google-ads, meta-ads) with per-tool subcommands - Tool name mapping: strips platform prefix, converts to kebab-case (e.g. get_google_ads_campaigns → get-campaigns) - JSON Schema inputSchema → typed CLI flags with descriptions, enum hints, and required enforcement - MCP text content is extracted and pretty-printed as JSON
1 parent 030f409 commit 84329b6

7 files changed

Lines changed: 584 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,43 @@ Get your API token at [pipeboard.co/settings](https://pipeboard.co/settings).
4444
pipeboard login
4545
```
4646

47+
## Quick Start
48+
49+
After authenticating, fetch the available tools:
50+
51+
```bash
52+
pipeboard refresh
53+
```
54+
55+
This downloads tool definitions from Pipeboard's MCP servers and caches them locally at `~/.pipeboard/tools-cache.json`. Run this once after install, or again when new tools are available.
56+
4757
## Usage
4858

49-
Commands are auto-generated from Pipeboard's MCP tool definitions. Run `pipeboard --help` to see available commands.
59+
Commands are auto-generated from Pipeboard's MCP tool definitions. Each ad platform is a top-level command with subcommands for every available operation.
5060

5161
```bash
5262
# List Google Ads campaigns
53-
pipeboard google-ads list-campaigns --customer-id 1234567890
63+
pipeboard google-ads get-campaigns --customer-id 1234567890
64+
65+
# Execute a raw GAQL query
66+
pipeboard google-ads execute-gaql-query --customer-id 1234567890 --query "SELECT campaign.id, campaign.name FROM campaign LIMIT 10"
67+
68+
# Get Meta Ads campaign insights
69+
pipeboard meta-ads get-insights --object-id act_123 --date-preset last_30d
5470

55-
# Get Meta Ads insights
56-
pipeboard meta-ads get-insights --account-id act_123 --date-range last_30d
71+
# Create a Meta Ads campaign
72+
pipeboard meta-ads create-campaign --account-id act_123 --name "Spring Sale" --objective OUTCOME_SALES
73+
74+
# See all available commands for a platform
75+
pipeboard google-ads --help
76+
pipeboard meta-ads --help
5777

5878
# Check version
5979
pipeboard version
6080
```
6181

82+
Flags are generated from each tool's JSON Schema — required flags are enforced and enum values are shown in help text.
83+
6284
## Development
6385

6486
```bash

‎cmd/cache.go‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"time"
8+
9+
"github.com/pipeboard-co/pipeboard-cli/internal/client"
10+
)
11+
12+
// ToolsCache stores tool definitions fetched from MCP servers.
13+
type ToolsCache struct {
14+
Servers map[string]ServerToolsCache `json:"servers"`
15+
UpdatedAt time.Time `json:"updated_at"`
16+
}
17+
18+
// ServerToolsCache holds the tools for a single MCP server.
19+
type ServerToolsCache struct {
20+
Tools []client.ToolDefinition `json:"tools"`
21+
}
22+
23+
func toolsCachePath() (string, error) {
24+
dir, err := configDir()
25+
if err != nil {
26+
return "", err
27+
}
28+
return filepath.Join(dir, "tools-cache.json"), nil
29+
}
30+
31+
func loadToolsCache() (*ToolsCache, error) {
32+
path, err := toolsCachePath()
33+
if err != nil {
34+
return nil, err
35+
}
36+
data, err := os.ReadFile(path)
37+
if err != nil {
38+
return nil, err
39+
}
40+
var cache ToolsCache
41+
if err := json.Unmarshal(data, &cache); err != nil {
42+
return nil, err
43+
}
44+
return &cache, nil
45+
}
46+
47+
func saveToolsCache(cache *ToolsCache) error {
48+
path, err := toolsCachePath()
49+
if err != nil {
50+
return err
51+
}
52+
dir := filepath.Dir(path)
53+
if err := os.MkdirAll(dir, 0700); err != nil {
54+
return err
55+
}
56+
data, err := json.MarshalIndent(cache, "", " ")
57+
if err != nil {
58+
return err
59+
}
60+
return os.WriteFile(path, data, 0600)
61+
}

‎cmd/dynamic.go‎

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/pipeboard-co/pipeboard-cli/internal/client"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// registerDynamicCommands loads the tools cache and registers platform commands.
15+
func registerDynamicCommands() {
16+
cache, err := loadToolsCache()
17+
if err != nil {
18+
return // No cache — user needs to run 'pipeboard refresh'
19+
}
20+
21+
for _, server := range knownServers {
22+
serverCache, ok := cache.Servers[server.Path]
23+
if !ok || len(serverCache.Tools) == 0 {
24+
continue
25+
}
26+
27+
platformCmd := &cobra.Command{
28+
Use: server.CommandName,
29+
Short: server.Description,
30+
}
31+
32+
for _, tool := range serverCache.Tools {
33+
cmd := buildToolCommand(server, tool)
34+
platformCmd.AddCommand(cmd)
35+
}
36+
37+
rootCmd.AddCommand(platformCmd)
38+
}
39+
}
40+
41+
// toolNameToCommandName converts an MCP tool name to a CLI command name.
42+
// It strips the platform prefix and converts underscores to hyphens.
43+
//
44+
// Examples:
45+
//
46+
// get_google_ads_campaigns → get-campaigns
47+
// execute_google_ads_gaql_query → execute-gaql-query
48+
// get_campaigns → get-campaigns
49+
func toolNameToCommandName(toolName, prefix string) string {
50+
name := toolName
51+
if prefix != "" {
52+
name = strings.Replace(name, prefix, "", 1)
53+
}
54+
return strings.ReplaceAll(name, "_", "-")
55+
}
56+
57+
// InputSchema is a simplified JSON Schema for MCP tool inputs.
58+
type InputSchema struct {
59+
Type string `json:"type"`
60+
Properties map[string]PropertySchema `json:"properties"`
61+
Required []string `json:"required"`
62+
}
63+
64+
// PropertySchema describes a single property in the input schema.
65+
type PropertySchema struct {
66+
Type interface{} `json:"type"` // string or []string (e.g. ["string", "null"])
67+
Description string `json:"description"`
68+
Enum []string `json:"enum"`
69+
}
70+
71+
// resolveType returns the primary type as a string, handling union types like ["string", "null"].
72+
func (p PropertySchema) resolveType() string {
73+
switch t := p.Type.(type) {
74+
case string:
75+
return t
76+
case []interface{}:
77+
for _, v := range t {
78+
if s, ok := v.(string); ok && s != "null" {
79+
return s
80+
}
81+
}
82+
}
83+
return "string"
84+
}
85+
86+
func buildToolCommand(server ServerConfig, tool client.ToolDefinition) *cobra.Command {
87+
cmdName := toolNameToCommandName(tool.Name, server.ToolPrefix)
88+
89+
var schema InputSchema
90+
if len(tool.InputSchema) > 0 {
91+
_ = json.Unmarshal(tool.InputSchema, &schema)
92+
}
93+
94+
requiredSet := make(map[string]bool)
95+
for _, r := range schema.Required {
96+
requiredSet[r] = true
97+
}
98+
99+
// Flag storage — one map per type we support
100+
stringFlags := make(map[string]*string)
101+
boolFlags := make(map[string]*bool)
102+
103+
cmd := &cobra.Command{
104+
Use: cmdName,
105+
Short: truncate(tool.Description, 80),
106+
Long: tool.Description,
107+
RunE: func(cmd *cobra.Command, args []string) error {
108+
return runDynamicTool(server, tool.Name, schema, stringFlags, boolFlags)
109+
},
110+
}
111+
112+
for propName, prop := range schema.Properties {
113+
flagName := strings.ReplaceAll(propName, "_", "-")
114+
desc := prop.Description
115+
if len(prop.Enum) > 0 {
116+
desc += " (options: " + strings.Join(prop.Enum, ", ") + ")"
117+
}
118+
119+
switch prop.resolveType() {
120+
case "boolean":
121+
boolFlags[propName] = cmd.Flags().Bool(flagName, false, desc)
122+
default:
123+
// string, number, integer, array, object — all accepted as strings
124+
stringFlags[propName] = cmd.Flags().String(flagName, "", desc)
125+
}
126+
127+
if requiredSet[propName] {
128+
cmd.MarkFlagRequired(flagName)
129+
}
130+
}
131+
132+
return cmd
133+
}
134+
135+
func runDynamicTool(server ServerConfig, toolName string, schema InputSchema, stringFlags map[string]*string, boolFlags map[string]*bool) error {
136+
token, err := getToken()
137+
if err != nil {
138+
return err
139+
}
140+
141+
c := client.New(apiURL, token)
142+
143+
fmt.Fprintf(os.Stderr, "Connecting to %s/%s...\n", apiURL, server.Path)
144+
if err := c.Initialize(server.Path); err != nil {
145+
return fmt.Errorf("handshake failed: %w", err)
146+
}
147+
148+
// Build arguments from flag values, coercing types based on schema
149+
toolArgs := make(map[string]interface{})
150+
151+
for propName, val := range stringFlags {
152+
if val == nil || *val == "" {
153+
continue
154+
}
155+
prop := schema.Properties[propName]
156+
switch prop.resolveType() {
157+
case "integer":
158+
if n, err := strconv.Atoi(*val); err == nil {
159+
toolArgs[propName] = n
160+
} else {
161+
toolArgs[propName] = *val
162+
}
163+
case "number":
164+
if n, err := strconv.ParseFloat(*val, 64); err == nil {
165+
toolArgs[propName] = n
166+
} else {
167+
toolArgs[propName] = *val
168+
}
169+
case "object", "array":
170+
var parsed interface{}
171+
if err := json.Unmarshal([]byte(*val), &parsed); err == nil {
172+
toolArgs[propName] = parsed
173+
} else {
174+
toolArgs[propName] = *val
175+
}
176+
default:
177+
toolArgs[propName] = *val
178+
}
179+
}
180+
181+
for propName, val := range boolFlags {
182+
if val != nil && *val {
183+
toolArgs[propName] = true
184+
}
185+
}
186+
187+
fmt.Fprintf(os.Stderr, "Calling %s...\n", toolName)
188+
result, err := c.CallTool(server.Path, toolName, toolArgs)
189+
if err != nil {
190+
return fmt.Errorf("tool call failed: %w", err)
191+
}
192+
193+
return printToolResult(result)
194+
}
195+
196+
// printToolResult extracts text content from the MCP tools/call response and prints it.
197+
func printToolResult(raw json.RawMessage) error {
198+
var toolResult struct {
199+
Content []struct {
200+
Type string `json:"type"`
201+
Text string `json:"text"`
202+
} `json:"content"`
203+
IsError bool `json:"isError"`
204+
}
205+
206+
if err := json.Unmarshal(raw, &toolResult); err == nil && len(toolResult.Content) > 0 {
207+
for _, item := range toolResult.Content {
208+
if item.Type != "text" {
209+
continue
210+
}
211+
// Try to pretty-print as JSON; fall back to plain text
212+
var parsed interface{}
213+
if err := json.Unmarshal([]byte(item.Text), &parsed); err == nil {
214+
enc := json.NewEncoder(os.Stdout)
215+
enc.SetIndent("", " ")
216+
enc.Encode(parsed)
217+
} else {
218+
fmt.Println(item.Text)
219+
}
220+
}
221+
if toolResult.IsError {
222+
return fmt.Errorf("tool returned an error")
223+
}
224+
return nil
225+
}
226+
227+
// Fallback: print raw result
228+
var parsed interface{}
229+
if err := json.Unmarshal(raw, &parsed); err == nil {
230+
return printJSON(parsed)
231+
}
232+
fmt.Println(string(raw))
233+
return nil
234+
}

0 commit comments

Comments
 (0)