Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cmd/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import (

// ToolsCache stores tool definitions fetched from MCP servers.
type ToolsCache struct {
Servers map[string]ServerToolsCache `json:"servers"`
UpdatedAt time.Time `json:"updated_at"`
Servers map[string]ServerToolsCache `json:"servers"`
UpdatedAt time.Time `json:"updated_at"`
Hash string `json:"hash,omitempty"`
LastCheckedAt time.Time `json:"last_checked_at,omitempty"`
}

// ServerToolsCache holds the tools for a single MCP server.
Expand Down
58 changes: 58 additions & 0 deletions cmd/dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"time"

"github.com/pipeboard-co/pipeboard-cli/internal/client"
"github.com/spf13/cobra"
)

const autoRefreshInterval = 1 * time.Hour

// registerDynamicCommands loads the tools cache and registers platform commands.
func registerDynamicCommands() {
cache, err := loadToolsCache()
Expand All @@ -36,6 +41,59 @@ func registerDynamicCommands() {

rootCmd.AddCommand(platformCmd)
}

// Check for tool updates in the background
maybeAutoRefresh(cache)
}

// maybeAutoRefresh checks whether the tools cache is stale and, if so,
// spawns a detached background process to check the hash and refresh if
// needed. The current command always uses the existing cache; updates
// take effect on the next invocation.
func maybeAutoRefresh(cache *ToolsCache) {
// Guard against recursive spawning: the child sets this env var so
// it won't spawn another child during its own init().
if os.Getenv("PIPEBOARD_AUTO_REFRESH") == "1" {
return
}

if time.Since(cache.LastCheckedAt) < autoRefreshInterval {
return
}

// Spawn a detached "pipeboard refresh --if-changed" subprocess.
// It survives parent exit and writes the cache for the next run.
self, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(self, "refresh", "--if-changed")
cmd.Env = append(os.Environ(), "PIPEBOARD_AUTO_REFRESH=1")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Stdout = nil
cmd.Stderr = nil
_ = cmd.Start()
}

// runAutoRefresh is the implementation behind "refresh --if-changed".
// It checks the hash first and only does a full refresh if tools changed.
func runAutoRefresh(baseURL, token string) error {
cache, _ := loadToolsCache()

hashResult, err := client.FetchToolsHash(baseURL)
if err != nil {
return err
}

if cache != nil && hashResult.Hash == cache.Hash {
// Tools unchanged — just bump the check timestamp
cache.LastCheckedAt = time.Now()
return saveToolsCache(cache)
}

// Hash changed or no cache — do a full refresh
_, err = fetchAndCacheTools(baseURL, token, false)
return err
}

// toolNameToCommandName converts an MCP tool name to a CLI command name.
Expand Down
69 changes: 54 additions & 15 deletions cmd/refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,53 +9,92 @@ import (
"github.com/spf13/cobra"
)

var ifChanged bool

var refreshCmd = &cobra.Command{
Use: "refresh",
Short: "Fetch and cache tool definitions from Pipeboard MCP servers",
Long: `Fetches available tools from all Pipeboard MCP servers and caches them locally.
This enables platform commands like 'pipeboard google-ads get-campaigns'.

Run this after first install or when new tools become available.`,
Normally you don't need to run this — the CLI automatically checks for
updates once per hour and refreshes in the background. Use this command
to force an immediate refresh.`,
RunE: runRefresh,
}

func init() {
refreshCmd.Flags().BoolVar(&ifChanged, "if-changed", false, "Only refresh if tools hash has changed (used by auto-refresh)")
refreshCmd.Flags().MarkHidden("if-changed")
}

func runRefresh(cmd *cobra.Command, args []string) error {
token, err := getToken()
if err != nil {
return err
}

c := client.New(apiURL, token)
cache := &ToolsCache{
Servers: make(map[string]ServerToolsCache),
UpdatedAt: time.Now(),
if ifChanged {
return runAutoRefresh(apiURL, token)
}

cache, err := fetchAndCacheTools(apiURL, token, true)
if err != nil {
return err
}

totalTools := 0
for _, sc := range cache.Servers {
totalTools += len(sc.Tools)
}
path, _ := toolsCachePath()
fmt.Fprintf(os.Stderr, "\nCached %d tools to %s\n", totalTools, path)
return nil
}

// fetchAndCacheTools fetches tools from all servers and saves the cache.
// If verbose is true, progress is printed to stderr.
func fetchAndCacheTools(baseURL, token string, verbose bool) (*ToolsCache, error) {
c := client.New(baseURL, token)
cache := &ToolsCache{
Servers: make(map[string]ServerToolsCache),
UpdatedAt: time.Now(),
LastCheckedAt: time.Now(),
}

for _, server := range knownServers {
fmt.Fprintf(os.Stderr, "Fetching tools from %s... ", server.Path)
if verbose {
fmt.Fprintf(os.Stderr, "Fetching tools from %s... ", server.Path)
}

if err := c.Initialize(server.Path); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
if verbose {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
}
continue
}

result, err := c.ListTools(server.Path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
if verbose {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
}
continue
}

cache.Servers[server.Path] = ServerToolsCache{Tools: result.Tools}
totalTools += len(result.Tools)
fmt.Fprintf(os.Stderr, "%d tools\n", len(result.Tools))
if verbose {
fmt.Fprintf(os.Stderr, "%d tools\n", len(result.Tools))
}
}

if err := saveToolsCache(cache); err != nil {
return fmt.Errorf("saving cache: %w", err)
// Fetch and store the current hash
if hashResult, err := client.FetchToolsHash(baseURL); err == nil {
cache.Hash = hashResult.Hash
}

path, _ := toolsCachePath()
fmt.Fprintf(os.Stderr, "\nCached %d tools to %s\n", totalTools, path)
return nil
if err := saveToolsCache(cache); err != nil {
return nil, fmt.Errorf("saving cache: %w", err)
}
return cache, nil
}
26 changes: 26 additions & 0 deletions internal/client/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,32 @@ func (c *Client) call(serverPath, method string, params interface{}) (json.RawMe
return rpcResp.Result, nil
}

// ToolsHashResult is the response from GET /tools-hash.
type ToolsHashResult struct {
Hash string `json:"hash"`
}

// FetchToolsHash retrieves the combined tools hash from the server.
// baseURL is the MCP base URL (e.g. https://mcp.pipeboard.co).
// This endpoint requires no authentication.
func FetchToolsHash(baseURL string) (*ToolsHashResult, error) {
resp, err := http.Get(baseURL + "/api/tools-hash")
if err != nil {
return nil, fmt.Errorf("fetching tools hash: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tools-hash HTTP %d", resp.StatusCode)
}

var result ToolsHashResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parsing tools-hash: %w", err)
}
return &result, nil
}

func (c *Client) notify(serverPath, method string, params interface{}) error {
// Notifications have no ID and expect no response.
type notification struct {
Expand Down
Loading