Skip to content
95 changes: 89 additions & 6 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func NewCommand(opts *internal.ToolboxOptions) *cobra.Command {
// TODO: Insecure by default. Might consider updating this for v1.0.0
flags.StringSliceVar(&opts.Cfg.AllowedOrigins, "allowed-origins", []string{"*"}, "Specifies a list of origins permitted to access this server. Defaults to '*'.")
flags.StringSliceVar(&opts.Cfg.AllowedHosts, "allowed-hosts", []string{"*"}, "Specifies a list of hosts permitted to access this server. Defaults to '*'.")
flags.IntVar(&opts.Cfg.PollInterval, "poll-interval", 0, "Specifies the polling frequency (seconds) for configuration file updates.")

// wrap RunE command so that we have access to original Command object
cmd.RunE = func(*cobra.Command, []string) error { return run(cmd, opts) }
Expand Down Expand Up @@ -195,16 +196,58 @@ func validateReloadEdits(
return sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, nil
}

// Helper to check if a file has a newer ModTime than stored in the map
func checkModTime(path string, mTime time.Time, lastSeen map[string]time.Time) bool {
if mTime.After(lastSeen[path]) {
lastSeen[path] = mTime
return true
}
return false
}

// Helper to scan watched files and check their modification times in polling system
func scanWatchedFiles(watchingFolder bool, folderToWatch string, watchedFiles map[string]bool, lastSeen map[string]time.Time) (map[string]bool, bool, error) {
changed := false
currentDiskFiles := make(map[string]bool)
if watchingFolder {
files, err := os.ReadDir(folderToWatch)
if err != nil {
return nil, changed, fmt.Errorf("error reading tools folder %w", err)
}
for _, f := range files {
if !f.IsDir() && (strings.HasSuffix(f.Name(), ".yaml") || strings.HasSuffix(f.Name(), ".yml")) {
fullPath := filepath.Join(folderToWatch, f.Name())
currentDiskFiles[fullPath] = true
if info, err := f.Info(); err == nil {
if checkModTime(fullPath, info.ModTime(), lastSeen) {
changed = true
}
}
}
}
} else {
for f := range watchedFiles {
if info, err := os.Stat(f); err == nil {
currentDiskFiles[f] = true
if checkModTime(f, info.ModTime(), lastSeen) {
changed = true
}
}
Comment thread
Yuan325 marked this conversation as resolved.
}
}
return currentDiskFiles, changed, nil
}

// watchChanges checks for changes in the provided yaml tools file(s) or folder.
func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles map[string]bool, s *server.Server) {
func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles map[string]bool, s *server.Server, pollTickerSecond int) {
logger, err := util.LoggerFromContext(ctx)
if err != nil {
panic(err)
}

w, err := fsnotify.NewWatcher()
if err != nil {
logger.WarnContext(ctx, "error setting up new watcher %s", err)
logger.WarnContext(ctx, fmt.Sprintf("error setting up new watcher %s", err))
Comment thread
Yuan325 marked this conversation as resolved.
return
}

Expand Down Expand Up @@ -238,6 +281,23 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
logger.DebugContext(ctx, fmt.Sprintf("Added directory %s to watcher.", dir))
}

lastSeen := make(map[string]time.Time)
var pollTickerChan <-chan time.Time
if pollTickerSecond > 0 {
ticker := time.NewTicker(time.Duration(pollTickerSecond) * time.Second)
defer ticker.Stop()
pollTickerChan = ticker.C // Assign the channel
logger.DebugContext(ctx, fmt.Sprintf("NFS polling enabled every %v", pollTickerSecond))
Comment thread
Yuan325 marked this conversation as resolved.

// Pre-populate lastSeen to avoid an initial spurious reload
_, _, err = scanWatchedFiles(watchingFolder, folderToWatch, watchedFiles, lastSeen)
if err != nil {
logger.WarnContext(ctx, err.Error())
}
} else {
logger.DebugContext(ctx, "NFS polling disabled (interval is 0)")
Comment thread
Yuan325 marked this conversation as resolved.
}

// debounce timer is used to prevent multiple writes triggering multiple reloads
debounceDelay := 100 * time.Millisecond
debounce := time.NewTimer(1 * time.Minute)
Expand All @@ -248,13 +308,36 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
case <-ctx.Done():
logger.DebugContext(ctx, "file watcher context cancelled")
return
case <-pollTickerChan:
// Get files that are currently on disk
currentDiskFiles, changed, err := scanWatchedFiles(watchingFolder, folderToWatch, watchedFiles, lastSeen)
if err != nil {
logger.WarnContext(ctx, err.Error())
continue
}

// Check for Deletions
// If it was in lastSeen but is NOT in currentDiskFiles, it's
// deleted; we will need to reload the server.
for path := range lastSeen {
if !currentDiskFiles[path] {
logger.DebugContext(ctx, fmt.Sprintf("File deleted (detected via polling): %s", path))
delete(lastSeen, path)
changed = true
}
}
if changed {
logger.DebugContext(ctx, "File change detected via polling")
// once this timer runs out, it will trigger debounce.C
debounce.Reset(debounceDelay)
}
case err, ok := <-w.Errors:
if !ok {
logger.WarnContext(ctx, "file watcher was closed unexpectedly")
Comment thread
Yuan325 marked this conversation as resolved.
return
}
if err != nil {
logger.WarnContext(ctx, "file watcher error %s", err)
logger.WarnContext(ctx, fmt.Sprintf("file watcher error %s", err))
return
}

Expand Down Expand Up @@ -289,14 +372,14 @@ func watchChanges(ctx context.Context, watchDirs map[string]bool, watchedFiles m
logger.DebugContext(ctx, "Reloading tools folder.")
reloadedToolsFile, err = internal.LoadAndMergeToolsFolder(ctx, folderToWatch)
if err != nil {
logger.WarnContext(ctx, "error loading tools folder %s", err)
logger.WarnContext(ctx, fmt.Sprintf("error loading tools folder %s", err))
continue
}
} else {
logger.DebugContext(ctx, "Reloading tools file(s).")
reloadedToolsFile, err = internal.LoadAndMergeToolsFiles(ctx, slices.Collect(maps.Keys(watchedFiles)))
if err != nil {
logger.WarnContext(ctx, "error loading tools files %s", err)
logger.WarnContext(ctx, fmt.Sprintf("error loading tools files %s", err))
continue
}
}
Expand Down Expand Up @@ -417,7 +500,7 @@ func run(cmd *cobra.Command, opts *internal.ToolboxOptions) error {
if isCustomConfigured && !opts.Cfg.DisableReload {
watchDirs, watchedFiles := resolveWatcherInputs(opts.ToolsFile, opts.ToolsFiles, opts.ToolsFolder)
// start watching the file(s) or folder for changes to trigger dynamic reloading
go watchChanges(ctx, watchDirs, watchedFiles, s)
go watchChanges(ctx, watchDirs, watchedFiles, s, opts.Cfg.PollInterval)
}

// wait for either the server to error out or the command's context to be canceled
Expand Down
2 changes: 1 addition & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ func TestSingleEdit(t *testing.T) {
watchedFiles := map[string]bool{cleanFileToWatch: true}
watchDirs := map[string]bool{watchDir: true}

go watchChanges(ctx, watchDirs, watchedFiles, mockServer)
go watchChanges(ctx, watchDirs, watchedFiles, mockServer, 0)

// escape backslash so regex doesn't fail on windows filepaths
regexEscapedPathFile := strings.ReplaceAll(cleanFileToWatch, `\`, `\\\\*\\`)
Expand Down
17 changes: 14 additions & 3 deletions docs/en/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ description: >
| | `--log-level` | Specify the minimum level logged. Allowed: 'DEBUG', 'INFO', 'WARN', 'ERROR'. | `info` |
| | `--logging-format` | Specify logging format to use. Allowed: 'standard' or 'JSON'. | `standard` |
| `-p` | `--port` | Port the server will listen on. | `5000` |
| | `--prebuilt` | Use one or more prebuilt tool configuration by source type. See [Prebuilt Tools Reference](prebuilt-tools.md) for allowed values. | |
| | `--prebuilt` | Use one or more prebuilt tool configuration by source type. See [Prebuilt Tools Reference](prebuilt-tools.md) for allowed values. | |
| | `--stdio` | Listens via MCP STDIO instead of acting as a remote HTTP server. | |
| | `--telemetry-gcp` | Enable exporting directly to Google Cloud Monitoring. | |
| | `--telemetry-otlp` | Enable exporting using OpenTelemetry Protocol (OTLP) to the specified endpoint (e.g. 'http://127.0.0.1:4318') | |
Expand All @@ -28,6 +28,7 @@ description: >
| | `--allowed-origins` | Specifies a list of origins permitted to access this server for CORs access. | `*` |
| | `--allowed-hosts` | Specifies a list of hosts permitted to access this server to prevent DNS rebinding attacks. | `*` |
| | `--user-agent-metadata` | Appends additional metadata to the User-Agent. | |
| | `--poll-interval` | Specifies the polling frequency (seconds) for configuration file updates. | `0` |
| `-v` | `--version` | version for toolbox | |

## Sub Commands
Expand Down Expand Up @@ -133,8 +134,18 @@ used at a time.

### Hot Reload

Toolbox enables dynamic reloading by default. To disable, use the
`--disable-reload` flag.
Toolbox supports two methods for detecting configuration changes: **Push**
(event-driven) and **Poll** (interval-based). To completely disable all hot
reloading, use the `--disable-reload` flag.

* **Push (Default):** Toolbox uses a highly efficient push system that listens
for instant OS-level file events to reload configurations the moment you save.
* **Poll (Fallback):** Alternatively, you can use the
`--poll-interval=<seconds>` flag to actively check for updates at a set
cadence. Unlike the push system, polling "pulls" the file status manually,
which is a great fallback for network drives or container volumes where OS
events might get dropped. Set the interval to `0` to disable the polling
system.

### Toolbox UI

Expand Down
2 changes: 2 additions & 0 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ type ServerConfig struct {
AllowedHosts []string
// UserAgentMetadata specifies additional metadata to append to the User-Agent string.
UserAgentMetadata []string
// PollInterval sets the polling frequency for configuration file updates.
PollInterval int
}

type logFormat string
Expand Down
Loading