Skip to content
This repository was archived by the owner on Apr 12, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 3 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
8 changes: 7 additions & 1 deletion internal/mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/mark3labs/mcp-go/client"
mcptransport "github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"

customErrors "github.com/tuannvm/slack-mcp-client/internal/common/errors"
Expand Down Expand Up @@ -93,7 +94,12 @@ func NewClient(transport, addressOrCommand string, serverName string, args []str
return nil, customErrors.WrapMCPError(err, "client_start", fmt.Sprintf("Failed to start MCP client for %s", addressOrCommand))
}
case "http":
mcpClient, err = client.NewStreamableHttpClient(addressOrCommand)
// Convert resolvedHeaders map to map[string]string for streamable HTTP transport
httpHeaders := make(map[string]string)
for k, v := range resolvedHeaders {
httpHeaders[k] = v
}
mcpClient, err = client.NewStreamableHttpClient(addressOrCommand, mcptransport.WithHTTPHeaders(httpHeaders))
if err != nil {
return nil, customErrors.WrapMCPError(err, "client_creation", fmt.Sprintf("Failed to create MCP client for %s", addressOrCommand))
}
Expand Down
33 changes: 29 additions & 4 deletions internal/slack/agentCallbackHandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package slackbot

import (
"context"
"strings"

"github.com/tmc/langchaingo/callbacks"
)

Expand All @@ -13,9 +15,32 @@ type agentCallbackHandler struct {
}

func (handler *agentCallbackHandler) HandleChainEnd(_ context.Context, outputs map[string]any) {
if text, ok := outputs["text"]; ok {
if textStr, ok := text.(string); ok {
handler.sendMessage(textStr)
}
text, ok := outputs["text"]
if !ok {
return
}
textStr, ok := text.(string)
if !ok {
return
}

// Only send the final answer to the user.
// Intermediate agent steps contain "Action:" and "Action Input:" lines
// which are internal reasoning and should not be shown.
// The final answer uses the format:
// Thought: Do I need to use a tool? No
// AI: <actual response>
if strings.Contains(textStr, "Action:") && strings.Contains(textStr, "Action Input:") {
// This is an intermediate tool-calling step — suppress it
return
}

// Strip the ReAct scaffolding prefix from the final answer
if idx := strings.Index(textStr, "AI:"); idx != -1 {
textStr = strings.TrimSpace(textStr[idx+3:])
}

if textStr != "" {
handler.sendMessage(textStr)
}
}
23 changes: 21 additions & 2 deletions internal/slack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Client struct {
historyLimit int
discoveredTools map[string]mcp.ToolInfo
tracingHandler observability.TracingHandler
activeThreads map[string]bool // channel:threadTS keys where bot has been mentioned
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unbounded growth of activeThreads map — entries are added but never evicted.

Every AppMentionEvent inserts a key into activeThreads, but there is no expiration, size cap, or cleanup path. For a long-running bot in an active workspace this map will grow indefinitely, leaking memory.

Consider one of:

  • A TTL-based eviction (e.g., remove entries older than N hours using a timestamp value instead of bool).
  • A bounded LRU / ring buffer.
  • Periodic pruning in a background goroutine.
Example: store timestamps and add a simple pruning helper
-	activeThreads   map[string]bool // channel:threadTS keys where bot has been mentioned
+	activeThreads   map[string]time.Time // channel:threadTS → last-activity time
-		c.activeThreads[threadKey] = true
+		c.activeThreads[threadKey] = time.Now()
-			isActiveThread = c.activeThreads[threadKey]
+			if ts, ok := c.activeThreads[threadKey]; ok && time.Since(ts) < 24*time.Hour {
+				isActiveThread = true
+			}

And periodically (or lazily) prune stale entries.

Also applies to: 269-273

🤖 Prompt for AI Agents
In `@internal/slack/client.go` at line 40, activeThreads currently uses
map[string]bool and is never evicted, causing unbounded growth; change it to
store timestamps (e.g., map[string]time.Time or map[string]int64) instead of
bool, update the code paths that insert keys (the AppMentionEvent handling where
entries are added) to write time.Now(), and add a pruning strategy — either a
simple TTL-based prune helper that removes entries older than N (called
periodically via a background goroutine or lazily on writes/reads), or replace
with a bounded LRU structure; ensure all access to activeThreads is protected by
the existing mutex (or add one) when reading/writing to avoid races.

}

// Message represents a message in the conversation history
Expand Down Expand Up @@ -201,6 +202,7 @@ func NewClient(userFrontend UserFrontend, stdLogger *logging.Logger, mcpClients
historyLimit: cfg.Slack.MessageHistory, // Store configured number of messages per channel
discoveredTools: discoveredTools,
tracingHandler: tracingHandler,
activeThreads: make(map[string]bool),
}, nil
}

Expand Down Expand Up @@ -264,6 +266,12 @@ func (c *Client) handleEventMessage(event slackevents.EventsAPIEvent) {
if parentTS == "" {
parentTS = ev.TimeStamp // Use the original message timestamp if no thread
}

// Track this thread so the bot stays engaged for follow-up messages
threadKey := fmt.Sprintf("%s:%s", ev.Channel, parentTS)
c.activeThreads[threadKey] = true
c.logger.InfoKV("Tracking active thread", "channel", ev.Channel, "threadTS", parentTS)

// Use handleUserPrompt for app mentions too, for consistency
go c.handleUserPrompt(strings.TrimSpace(messageText), ev.Channel, parentTS, ev.TimeStamp, profile)

Expand All @@ -273,8 +281,19 @@ func (c *Client) handleEventMessage(event slackevents.EventsAPIEvent) {
isNotEdited := ev.SubType != "message_changed"
isBot := ev.BotID != "" || ev.SubType == "bot_message"

if isDirectMessage && isValidUser && isNotEdited && !isBot {
c.logger.InfoKV("Received direct message in channel", "channel", ev.Channel, "user", ev.User, "text", ev.Text, "ThreadTS", ev.ThreadTimeStamp)
// Check if this is a reply in a thread the bot is already participating in
isActiveThread := false
if ev.ThreadTimeStamp != "" {
threadKey := fmt.Sprintf("%s:%s", ev.Channel, ev.ThreadTimeStamp)
isActiveThread = c.activeThreads[threadKey]
}

if (isDirectMessage || isActiveThread) && isValidUser && isNotEdited && !isBot {
if isActiveThread {
c.logger.InfoKV("Received thread follow-up (no @mention needed)", "channel", ev.Channel, "user", ev.User, "text", ev.Text, "ThreadTS", ev.ThreadTimeStamp)
} else {
c.logger.InfoKV("Received direct message in channel", "channel", ev.Channel, "user", ev.User, "text", ev.Text, "ThreadTS", ev.ThreadTimeStamp)
}
profile, err := c.userFrontend.GetUserInfo(ev.User)
if err != nil {
c.logger.WarnKV("Failed to get user info", "user", ev.User, "error", err)
Expand Down
Loading