startMCPConfigWatcher boots an fsnotify watcher on the MCP config file's directory and triggers Manager.Reload on edits, so changes to mcp_servers.json take effect without restarting chatcli. We watch the directory rather than the file because most editors rewrite via rename (write to tmp, rename o
()
| 1697 | // per-file watcher misses entirely. Events are debounced to avoid |
| 1698 | // reloading mid-write. |
| 1699 | func (cli *ChatCLI) startMCPConfigWatcher() { |
| 1700 | if cli.mcpManager == nil || cli.mcpConfigPath == "" { |
| 1701 | return |
| 1702 | } |
| 1703 | w, err := fsnotify.NewWatcher() |
| 1704 | if err != nil { |
| 1705 | cli.logger.Warn("MCP config hot-reload disabled: cannot create watcher", zap.Error(err)) |
| 1706 | return |
| 1707 | } |
| 1708 | dir := filepath.Dir(cli.mcpConfigPath) |
| 1709 | if err := w.Add(dir); err != nil { |
| 1710 | cli.logger.Warn("MCP config hot-reload disabled: cannot watch dir", |
| 1711 | zap.String("dir", dir), zap.Error(err)) |
| 1712 | _ = w.Close() |
| 1713 | return |
| 1714 | } |
| 1715 | cli.mcpWatcher = w |
| 1716 | cli.mcpWatcherDone = make(chan struct{}) |
| 1717 | |
| 1718 | go func() { |
| 1719 | var debounce *time.Timer |
| 1720 | fire := func() { |
| 1721 | diff, err := cli.mcpManager.Reload(cli.mcpCtx, cli.mcpConfigPath) |
| 1722 | if err != nil { |
| 1723 | cli.logger.Warn("MCP config reload failed", zap.Error(err)) |
| 1724 | return |
| 1725 | } |
| 1726 | if len(diff.Started)+len(diff.Stopped)+len(diff.Updated) == 0 { |
| 1727 | return |
| 1728 | } |
| 1729 | cli.logger.Info("MCP config hot-reloaded", |
| 1730 | zap.Strings("started", diff.Started), |
| 1731 | zap.Strings("stopped", diff.Stopped), |
| 1732 | zap.Strings("updated", diff.Updated)) |
| 1733 | } |
| 1734 | for { |
| 1735 | select { |
| 1736 | case <-cli.mcpWatcherDone: |
| 1737 | return |
| 1738 | case ev, ok := <-w.Events: |
| 1739 | if !ok { |
| 1740 | return |
| 1741 | } |
| 1742 | // Only react to events on the config file itself (the |
| 1743 | // directory watcher sees siblings too). |
| 1744 | if filepath.Clean(ev.Name) != filepath.Clean(cli.mcpConfigPath) { |
| 1745 | continue |
| 1746 | } |
| 1747 | if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 { |
| 1748 | continue |
| 1749 | } |
| 1750 | if debounce != nil { |
| 1751 | debounce.Stop() |
| 1752 | } |
| 1753 | debounce = time.AfterFunc(400*time.Millisecond, fire) |
| 1754 | case err, ok := <-w.Errors: |
| 1755 | if !ok { |
| 1756 | return |