Skip to content

Commit e089604

Browse files
authored
feat: auto-refresh tool cache via background hash check (#10)
* feat: auto-refresh tool cache via background hash check Checks /api/tools-hash once per hour in a detached subprocess. If the hash differs from the cached value, a full refresh runs in the background. The current command is never blocked — updates take effect on the next invocation. * fix: prevent recursive subprocess spawning in auto-refresh The background auto-refresh child process would re-enter maybeAutoRefresh during init and spawn another child before updating the cache. Guard with PIPEBOARD_AUTO_REFRESH env var.
1 parent feed15f commit e089604

4 files changed

Lines changed: 142 additions & 17 deletions

File tree

‎cmd/cache.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111

1212
// ToolsCache stores tool definitions fetched from MCP servers.
1313
type ToolsCache struct {
14-
Servers map[string]ServerToolsCache `json:"servers"`
15-
UpdatedAt time.Time `json:"updated_at"`
14+
Servers map[string]ServerToolsCache `json:"servers"`
15+
UpdatedAt time.Time `json:"updated_at"`
16+
Hash string `json:"hash,omitempty"`
17+
LastCheckedAt time.Time `json:"last_checked_at,omitempty"`
1618
}
1719

1820
// ServerToolsCache holds the tools for a single MCP server.

‎cmd/dynamic.go‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"os/exec"
78
"strconv"
89
"strings"
10+
"syscall"
11+
"time"
912

1013
"github.com/pipeboard-co/pipeboard-cli/internal/client"
1114
"github.com/spf13/cobra"
1215
)
1316

17+
const autoRefreshInterval = 1 * time.Hour
18+
1419
// registerDynamicCommands loads the tools cache and registers platform commands.
1520
func registerDynamicCommands() {
1621
cache, err := loadToolsCache()
@@ -36,6 +41,59 @@ func registerDynamicCommands() {
3641

3742
rootCmd.AddCommand(platformCmd)
3843
}
44+
45+
// Check for tool updates in the background
46+
maybeAutoRefresh(cache)
47+
}
48+
49+
// maybeAutoRefresh checks whether the tools cache is stale and, if so,
50+
// spawns a detached background process to check the hash and refresh if
51+
// needed. The current command always uses the existing cache; updates
52+
// take effect on the next invocation.
53+
func maybeAutoRefresh(cache *ToolsCache) {
54+
// Guard against recursive spawning: the child sets this env var so
55+
// it won't spawn another child during its own init().
56+
if os.Getenv("PIPEBOARD_AUTO_REFRESH") == "1" {
57+
return
58+
}
59+
60+
if time.Since(cache.LastCheckedAt) < autoRefreshInterval {
61+
return
62+
}
63+
64+
// Spawn a detached "pipeboard refresh --if-changed" subprocess.
65+
// It survives parent exit and writes the cache for the next run.
66+
self, err := os.Executable()
67+
if err != nil {
68+
return
69+
}
70+
cmd := exec.Command(self, "refresh", "--if-changed")
71+
cmd.Env = append(os.Environ(), "PIPEBOARD_AUTO_REFRESH=1")
72+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
73+
cmd.Stdout = nil
74+
cmd.Stderr = nil
75+
_ = cmd.Start()
76+
}
77+
78+
// runAutoRefresh is the implementation behind "refresh --if-changed".
79+
// It checks the hash first and only does a full refresh if tools changed.
80+
func runAutoRefresh(baseURL, token string) error {
81+
cache, _ := loadToolsCache()
82+
83+
hashResult, err := client.FetchToolsHash(baseURL)
84+
if err != nil {
85+
return err
86+
}
87+
88+
if cache != nil && hashResult.Hash == cache.Hash {
89+
// Tools unchanged — just bump the check timestamp
90+
cache.LastCheckedAt = time.Now()
91+
return saveToolsCache(cache)
92+
}
93+
94+
// Hash changed or no cache — do a full refresh
95+
_, err = fetchAndCacheTools(baseURL, token, false)
96+
return err
3997
}
4098

4199
// toolNameToCommandName converts an MCP tool name to a CLI command name.

‎cmd/refresh.go‎

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,53 +9,92 @@ import (
99
"github.com/spf13/cobra"
1010
)
1111

12+
var ifChanged bool
13+
1214
var refreshCmd = &cobra.Command{
1315
Use: "refresh",
1416
Short: "Fetch and cache tool definitions from Pipeboard MCP servers",
1517
Long: `Fetches available tools from all Pipeboard MCP servers and caches them locally.
1618
This enables platform commands like 'pipeboard google-ads get-campaigns'.
1719
18-
Run this after first install or when new tools become available.`,
20+
Normally you don't need to run this — the CLI automatically checks for
21+
updates once per hour and refreshes in the background. Use this command
22+
to force an immediate refresh.`,
1923
RunE: runRefresh,
2024
}
2125

26+
func init() {
27+
refreshCmd.Flags().BoolVar(&ifChanged, "if-changed", false, "Only refresh if tools hash has changed (used by auto-refresh)")
28+
refreshCmd.Flags().MarkHidden("if-changed")
29+
}
30+
2231
func runRefresh(cmd *cobra.Command, args []string) error {
2332
token, err := getToken()
2433
if err != nil {
2534
return err
2635
}
2736

28-
c := client.New(apiURL, token)
29-
cache := &ToolsCache{
30-
Servers: make(map[string]ServerToolsCache),
31-
UpdatedAt: time.Now(),
37+
if ifChanged {
38+
return runAutoRefresh(apiURL, token)
39+
}
40+
41+
cache, err := fetchAndCacheTools(apiURL, token, true)
42+
if err != nil {
43+
return err
3244
}
3345

3446
totalTools := 0
47+
for _, sc := range cache.Servers {
48+
totalTools += len(sc.Tools)
49+
}
50+
path, _ := toolsCachePath()
51+
fmt.Fprintf(os.Stderr, "\nCached %d tools to %s\n", totalTools, path)
52+
return nil
53+
}
54+
55+
// fetchAndCacheTools fetches tools from all servers and saves the cache.
56+
// If verbose is true, progress is printed to stderr.
57+
func fetchAndCacheTools(baseURL, token string, verbose bool) (*ToolsCache, error) {
58+
c := client.New(baseURL, token)
59+
cache := &ToolsCache{
60+
Servers: make(map[string]ServerToolsCache),
61+
UpdatedAt: time.Now(),
62+
LastCheckedAt: time.Now(),
63+
}
64+
3565
for _, server := range knownServers {
36-
fmt.Fprintf(os.Stderr, "Fetching tools from %s... ", server.Path)
66+
if verbose {
67+
fmt.Fprintf(os.Stderr, "Fetching tools from %s... ", server.Path)
68+
}
3769

3870
if err := c.Initialize(server.Path); err != nil {
39-
fmt.Fprintf(os.Stderr, "error: %v\n", err)
71+
if verbose {
72+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
73+
}
4074
continue
4175
}
4276

4377
result, err := c.ListTools(server.Path)
4478
if err != nil {
45-
fmt.Fprintf(os.Stderr, "error: %v\n", err)
79+
if verbose {
80+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
81+
}
4682
continue
4783
}
4884

4985
cache.Servers[server.Path] = ServerToolsCache{Tools: result.Tools}
50-
totalTools += len(result.Tools)
51-
fmt.Fprintf(os.Stderr, "%d tools\n", len(result.Tools))
86+
if verbose {
87+
fmt.Fprintf(os.Stderr, "%d tools\n", len(result.Tools))
88+
}
5289
}
5390

54-
if err := saveToolsCache(cache); err != nil {
55-
return fmt.Errorf("saving cache: %w", err)
91+
// Fetch and store the current hash
92+
if hashResult, err := client.FetchToolsHash(baseURL); err == nil {
93+
cache.Hash = hashResult.Hash
5694
}
5795

58-
path, _ := toolsCachePath()
59-
fmt.Fprintf(os.Stderr, "\nCached %d tools to %s\n", totalTools, path)
60-
return nil
96+
if err := saveToolsCache(cache); err != nil {
97+
return nil, fmt.Errorf("saving cache: %w", err)
98+
}
99+
return cache, nil
61100
}

‎internal/client/mcp.go‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,32 @@ func (c *Client) call(serverPath, method string, params interface{}) (json.RawMe
165165
return rpcResp.Result, nil
166166
}
167167

168+
// ToolsHashResult is the response from GET /tools-hash.
169+
type ToolsHashResult struct {
170+
Hash string `json:"hash"`
171+
}
172+
173+
// FetchToolsHash retrieves the combined tools hash from the server.
174+
// baseURL is the MCP base URL (e.g. https://mcp.pipeboard.co).
175+
// This endpoint requires no authentication.
176+
func FetchToolsHash(baseURL string) (*ToolsHashResult, error) {
177+
resp, err := http.Get(baseURL + "/api/tools-hash")
178+
if err != nil {
179+
return nil, fmt.Errorf("fetching tools hash: %w", err)
180+
}
181+
defer resp.Body.Close()
182+
183+
if resp.StatusCode != http.StatusOK {
184+
return nil, fmt.Errorf("tools-hash HTTP %d", resp.StatusCode)
185+
}
186+
187+
var result ToolsHashResult
188+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
189+
return nil, fmt.Errorf("parsing tools-hash: %w", err)
190+
}
191+
return &result, nil
192+
}
193+
168194
func (c *Client) notify(serverPath, method string, params interface{}) error {
169195
// Notifications have no ID and expect no response.
170196
type notification struct {

0 commit comments

Comments
 (0)