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
10 changes: 10 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"
"unicode/utf8"

"telegram-agent/internal/config"
"telegram-agent/internal/llm"
"telegram-agent/internal/mcp"
"telegram-agent/internal/store"
Expand Down Expand Up @@ -231,6 +232,15 @@ func (a *Agent) EmbedText(ctx context.Context, text string) ([]float32, error) {
return a.mcp.EmbedText(ctx, text)
}

// ReloadMCP re-reads the MCP config file, reconnects all servers, and re-discovers tools.
// Returns the number of tools available after reload.
func (a *Agent) ReloadMCP(ctx context.Context, configs map[string]config.MCPServerConfig) (int, error) {
if a.mcp == nil {
return 0, fmt.Errorf("MCP client not initialized")
}
return a.mcp.Reconnect(ctx, configs)
}

func (a *Agent) ListTools() []ToolInfo {
var result []ToolInfo
if a.mcp != nil {
Expand Down
51 changes: 51 additions & 0 deletions internal/mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,57 @@ func (c *Client) Close() {
}
}

// Reconnect closes existing connections, replaces the server list with new configs,
// re-initializes all servers, and re-embeds tools if embeddings were enabled.
// Returns the number of tools discovered after reconnect.
func (c *Client) Reconnect(ctx context.Context, configs map[string]config.MCPServerConfig) (int, error) {
// Close old connections.
c.Close()

// Reset state.
c.servers = make(map[string]*server)
c.tools = nil
c.toolServers = make(map[string]string)
c.embeddingsReady = false

// Build new servers (same logic as NewClient).
for name, cfg := range configs {
if err := validateServerURL(cfg.URL); err != nil {
c.logger.Warn("mcp server URL rejected", "server", name, "url", cfg.URL, "err", err)
continue
}
srv := &server{
name: name,
url: cfg.URL,
headers: cfg.Headers,
http: &http.Client{Timeout: 30 * time.Second},
}
if len(cfg.AllowTools) > 0 {
srv.allowTools = make(map[string]bool, len(cfg.AllowTools))
for _, t := range cfg.AllowTools {
srv.allowTools[t] = true
}
}
if len(cfg.DenyTools) > 0 {
srv.denyTools = make(map[string]bool, len(cfg.DenyTools))
for _, t := range cfg.DenyTools {
srv.denyTools[t] = true
}
}
c.servers[name] = srv
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated server-building logic in Reconnect and NewClient

Low Severity

The server construction loop in Reconnect (URL validation, server struct creation, allow/deny tool maps) is a near-exact copy of NewClient. Duplicating this logic means future changes (e.g., new server fields or validation rules) need to be applied in both places, risking inconsistent behavior.

Additional Locations (1)
Fix in Cursor Fix in Web


// Initialize and discover tools.
c.Initialize(ctx)

// Re-embed tools if embeddings were configured.
if c.embeddingCfg.APIKey != "" || c.embeddingCfg.BaseURL != "" {
c.EmbedTools(ctx)
}

return len(c.tools), nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent map access causes fatal runtime panic

High Severity

Reconnect writes to c.servers, c.toolServers, and c.tools without any synchronization. The Telegram handler dispatches up to 10 updates concurrently in goroutines, so a /mcp update command can race with a user message that reads these same maps via CallTool or LLMToolsForQuery. In Go, concurrent read+write on a map is a fatal runtime panic — not just a data race.

Additional Locations (1)
Fix in Cursor Fix in Web


// CallTool executes a tool on the appropriate MCP server.
// On network failure it attempts one reconnect before giving up.
func (c *Client) CallTool(ctx context.Context, name string, argsJSON string) (string, error) {
Expand Down
32 changes: 32 additions & 0 deletions internal/telegram/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ func (h *Handler) handleCommand(msg *tgbotapi.Message) {
"/model <name> — switch model\n"+
"/model reset — back to auto\\-routing\n"+
"/tools — list MCP tools\n"+
"/mcp update — reload MCP servers\n"+
"/help — this help\n\n"+
"*Model:* `%s`\n"+
"Responses longer than 4096 chars are sent as a `.md` file\\.",
Expand Down Expand Up @@ -690,6 +691,8 @@ func (h *Handler) handleCommand(msg *tgbotapi.Message) {
h.bot.Send(msg) //nolint:errcheck
case "tools":
h.handleToolsCommand(chatID)
case "mcp":
h.handleMCPCommand(chatID, msg.CommandArguments())
case "stats":
h.handleStatsCommand(chatID)
default:
Expand Down Expand Up @@ -991,13 +994,42 @@ func registerCommands(bot *tgbotapi.BotAPI) error {
{Command: "model", Description: "Show / switch model"},
{Command: "routing", Description: "Configure routing (inline UI)"},
{Command: "tools", Description: "List connected MCP tools"},
{Command: "mcp", Description: "MCP management (update/reload)"},
{Command: "stats", Description: "Show history size, model, last compact"},
{Command: "help", Description: "Help"},
}
_, err := bot.Request(tgbotapi.NewSetMyCommands(commands...))
return err
}

func (h *Handler) handleMCPCommand(chatID int64, args string) {
switch strings.TrimSpace(args) {
case "update", "reload":
h.sendPlain(chatID, "Reloading MCP servers...")
configs, err := config.LoadMCPServers("config/mcp.json")
if err != nil {
h.sendPlain(chatID, "Error loading mcp.json: "+err.Error())
return
}
if len(configs) == 0 {
h.sendPlain(chatID, "No MCP servers configured in mcp.json.")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
toolCount, err := h.agent.ReloadMCP(ctx, configs)
if err != nil {
h.sendPlain(chatID, "Error: "+err.Error())
return
}
h.sendPlain(chatID, fmt.Sprintf("MCP reloaded: %d servers, %d tools.", len(configs), toolCount))
default:
h.send(chatID,
"*MCP commands:*\n\n"+
"/mcp update — reload mcp\\.json and reconnect all servers")
}
}

func (h *Handler) handleToolsCommand(chatID int64) {
tools := h.agent.ListTools()
if len(tools) == 0 {
Expand Down
Loading