Skip to content

Commit 992184e

Browse files
Alexey Panfilovclaude
andcommitted
feat(mcp): hot-reload bot client on admin UI save/delete
Saving or deleting an MCP server in the admin UI only persisted to kv_settings — the bot's MCP client kept its stale connections until a container restart. /mcp update also still pointed at the unmounted legacy config/mcp.json file, so the emergency fallback was broken too. - adminapi.Server gains an optional MCPReloader hook; set/delete handlers call it synchronously (30 s timeout) after saveMCPServers and surface success/failure in the page flash. - Telegram /mcp update now reads from a user-supplied loader, wired in main.go to adminapi.LoadMCPServersFromSettings (kv_settings first, file fallback for local dev). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 519345e commit 992184e

5 files changed

Lines changed: 83 additions & 5 deletions

File tree

cmd/agent/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,16 @@ func main() {
385385
if cfg.AdminAPI.Enabled && cfg.AdminAPI.BaseURL != "" {
386386
handler.SetAdminBaseURL(cfg.AdminAPI.BaseURL)
387387
}
388+
// /mcp update reads the live config from kv_settings (source of truth),
389+
// falling back to whatever the legacy file holds when the DB is empty.
390+
handler.SetMCPLoader(func(ctx context.Context) (map[string]config.MCPServerConfig, error) {
391+
if servers, found, err := adminapi.LoadMCPServersFromSettings(ctx, settingsStore); err != nil {
392+
return nil, err
393+
} else if found {
394+
return servers, nil
395+
}
396+
return config.LoadMCPServers("config/mcp.json")
397+
})
388398

389399
if cfg.VoiceAPI.Enabled {
390400
// chat_id: DB override > config. Non-destructive: if no DB value,
@@ -409,6 +419,7 @@ func main() {
409419
adminCfg := cfg.AdminAPI
410420
adminCfg.TrustForwardAuth = llm.GetBoolSetting(settingsCtx, settingsStore, llm.SettingKeyTrustForwardAuth, adminCfg.TrustForwardAuth)
411421
adminSrv := adminapi.New(adminCfg, router, capStore, settingsStore, usageStore, cfg, logger)
422+
adminSrv.SetMCPReloader(ag)
412423
if err := adminSrv.Start(); err != nil {
413424
logger.Error("admin API failed to start", "err", err)
414425
} else {

internal/adminapi/handlers.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,28 @@ type uiMCPData struct {
679679
Servers []uiMCPRow
680680
BridgeExport string // MCP_BRIDGE_EXPORT_PATH if set, shown as a banner note
681681
SavedName string // non-empty after a successful save — used by template to flash a success message
682+
ReloadOK string // non-empty on successful live reload (e.g. "3 servers, 42 tools")
683+
ReloadErr string // non-empty if the live reload failed after the DB save
684+
}
685+
686+
// reloadMCPLive hot-reloads the bot's MCP client with the given config set.
687+
// Uses a detached context with a 30 s timeout — the request's context is
688+
// typically 5 s which isn't enough to reconnect several servers. Returns
689+
// human-readable strings for the UI flash; empty strings if no reloader is
690+
// wired.
691+
func (s *Server) reloadMCPLive(servers map[string]config.MCPServerConfig) (okMsg, errMsg string) {
692+
if s.reloader == nil {
693+
return "", ""
694+
}
695+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
696+
defer cancel()
697+
toolCount, err := s.reloader.ReloadMCP(ctx, servers)
698+
if err != nil {
699+
s.logger.Warn("mcp live reload failed", "err", err)
700+
return "", err.Error()
701+
}
702+
s.logger.Info("mcp live reload ok", "servers", len(servers), "tools", toolCount)
703+
return fmt.Sprintf("%d servers, %d tools", len(servers), toolCount), ""
682704
}
683705

684706
// loadMCPForUI returns the current MCP list flattened into UI rows. Prefers
@@ -829,12 +851,15 @@ func (s *Server) handleMCPSet(w http.ResponseWriter, r *http.Request) {
829851
return
830852
}
831853
s.logger.Info("mcp server updated", "name", name, "url", url)
854+
reloadOK, reloadErr := s.reloadMCPLive(current)
832855
// Re-render the page so the new row shows up.
833856
data := uiMCPData{
834857
ActiveTab: "mcp",
835858
Servers: mcpToRows(current),
836859
BridgeExport: os.Getenv("MCP_BRIDGE_EXPORT_PATH"),
837860
SavedName: name,
861+
ReloadOK: reloadOK,
862+
ReloadErr: reloadErr,
838863
}
839864
w.Header().Set("Content-Type", "text/html; charset=utf-8")
840865
if err := render(w, viewMCP, data); err != nil {
@@ -873,10 +898,13 @@ func (s *Server) handleMCPDelete(w http.ResponseWriter, r *http.Request) {
873898
return
874899
}
875900
s.logger.Info("mcp server deleted", "name", name)
901+
reloadOK, reloadErr := s.reloadMCPLive(current)
876902
data := uiMCPData{
877903
ActiveTab: "mcp",
878904
Servers: mcpToRows(current),
879905
BridgeExport: os.Getenv("MCP_BRIDGE_EXPORT_PATH"),
906+
ReloadOK: reloadOK,
907+
ReloadErr: reloadErr,
880908
}
881909
w.Header().Set("Content-Type", "text/html; charset=utf-8")
882910
_ = render(w, viewMCP, data)

internal/adminapi/server.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import (
1717
"telegram-agent/internal/llm"
1818
)
1919

20+
// MCPReloader is the minimal surface the admin UI needs from the bot's MCP
21+
// client to hot-reload after a save/delete. Agent implements it; nil-safe.
22+
type MCPReloader interface {
23+
ReloadMCP(ctx context.Context, configs map[string]config.MCPServerConfig) (int, error)
24+
}
25+
2026
// Server wraps an http.Server + the upstream dependencies it needs to render
2127
// and mutate routing state.
2228
type Server struct {
@@ -26,11 +32,16 @@ type Server struct {
2632
settings llm.SettingsStore // for AA cache persistence; may be nil
2733
usageStore llm.UsageStore // for Usage/Cost section; may be nil
2834
cfgRef *config.Config // needed for enumerating OpenRouter slots
35+
reloader MCPReloader // may be nil (local dev / tests)
2936
logger *slog.Logger
3037

3138
httpSrv *http.Server
3239
}
3340

41+
// SetMCPReloader wires the hot-reload hook so saving/deleting MCP servers in
42+
// the UI applies immediately without a container restart.
43+
func (s *Server) SetMCPReloader(r MCPReloader) { s.reloader = r }
44+
3445
// New constructs the admin API server but does not start it. Call Start to
3546
// bind the listener.
3647
func New(cfg config.AdminAPIConfig, router *llm.Router, capStore llm.CapabilityStore, settings llm.SettingsStore, usageStore llm.UsageStore, cfgRef *config.Config, logger *slog.Logger) *Server {

internal/adminapi/templates/mcp.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
.mcp-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
1515
.mcp-bridge-note { background: #fff3e0; color: #92400e; padding: 0.5rem 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9em; }
1616
.mcp-flash-ok { background: #dcfce7; color: #15803d; padding: 0.5rem 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9em; }
17+
.mcp-flash-err { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9em; }
1718
</style>
1819
</head>
1920
<body>
@@ -24,6 +25,14 @@ <h1>Admin</h1>
2425
<div class="mcp-flash-ok">Saved <code>{{.SavedName}}</code>.</div>
2526
{{end}}
2627

28+
{{if .ReloadOK}}
29+
<div class="mcp-flash-ok">Bot MCP reloaded: {{.ReloadOK}}.</div>
30+
{{end}}
31+
32+
{{if .ReloadErr}}
33+
<div class="mcp-flash-err">Saved, but live reload failed: <code>{{.ReloadErr}}</code>. Restart the container or run <code>/mcp update</code> to retry.</div>
34+
{{end}}
35+
2736
{{if .BridgeExport}}
2837
<div class="mcp-bridge-note">
2938
<strong>Bridge mirror active.</strong> Every save also writes to

internal/telegram/handler.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ type Handler struct {
8181
adminBaseURL string // public URL of the admin web UI (empty = feature off)
8282
logger *slog.Logger
8383

84+
// mcpLoader returns the current MCP config set. When set, /mcp update
85+
// reads from here (the DB in prod) instead of the legacy file. May be nil.
86+
mcpLoader func(ctx context.Context) (map[string]config.MCPServerConfig, error)
87+
8488
forwardMu sync.Mutex
8589
forwardBuf map[int64]*forwardedContent
8690

@@ -90,6 +94,13 @@ type Handler struct {
9094
sem chan struct{} // concurrency limiter for handleUpdate goroutines
9195
}
9296

97+
// SetMCPLoader wires the source of truth for the /mcp update command. Pass a
98+
// closure reading from kv_settings so the command reflects the admin UI's
99+
// edits instead of the now-unmounted config/mcp.json.
100+
func (h *Handler) SetMCPLoader(f func(ctx context.Context) (map[string]config.MCPServerConfig, error)) {
101+
h.mcpLoader = f
102+
}
103+
93104
// SetAdminBaseURL sets the public admin UI URL surfaced in /routing as a
94105
// "Open Admin UI" button. Empty string disables the button.
95106
func (h *Handler) SetAdminBaseURL(u string) { h.adminBaseURL = u }
@@ -1378,17 +1389,25 @@ func (h *Handler) handleMCPCommand(chatID int64, args string) {
13781389
switch strings.TrimSpace(args) {
13791390
case "update", "reload":
13801391
h.sendPlain(chatID, "Reloading MCP servers...")
1381-
configs, err := config.LoadMCPServers("config/mcp.json")
1392+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1393+
defer cancel()
1394+
var (
1395+
configs map[string]config.MCPServerConfig
1396+
err error
1397+
)
1398+
if h.mcpLoader != nil {
1399+
configs, err = h.mcpLoader(ctx)
1400+
} else {
1401+
configs, err = config.LoadMCPServers("config/mcp.json")
1402+
}
13821403
if err != nil {
1383-
h.sendPlain(chatID, "Error loading mcp.json: "+err.Error())
1404+
h.sendPlain(chatID, "Error loading MCP config: "+err.Error())
13841405
return
13851406
}
13861407
if len(configs) == 0 {
1387-
h.sendPlain(chatID, "No MCP servers configured in mcp.json.")
1408+
h.sendPlain(chatID, "No MCP servers configured.")
13881409
return
13891410
}
1390-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1391-
defer cancel()
13921411
toolCount, err := h.agent.ReloadMCP(ctx, configs)
13931412
if err != nil {
13941413
h.sendPlain(chatID, "Error: "+err.Error())

0 commit comments

Comments
 (0)