Skip to content
Open
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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ Get list of channels
- `limit` (number, default: 100): The maximum number of items to return. Must be an integer between 1 and 1000 (maximum 999).
- `cursor` (string, optional): Cursor for pagination. Use the value of the last row and column in the response as next_cursor field returned from the previous request.

### 6. reactions_add:
Add an emoji reaction to a message in a public channel, private channel, or direct message (DM, or IM) conversation.

> **Note:** Adding reactions is disabled by default for safety. To enable, set the `SLACK_MCP_ADD_MESSAGE_TOOL` environment variable. If set to a comma-separated list of channel IDs, reactions are enabled only for those specific channels. See the Environment Variables section below for details.

- **Parameters:**
- `channel_id` (string, required): ID of the channel in format `Cxxxxxxxxxx` or its name starting with `#...` or `@...` aka `#general` or `@username_dm`.
- `timestamp` (string, required): Timestamp of the message to add reaction to, in format `1234567890.123456`.
- `emoji` (string, required): The name of the emoji to add as a reaction (without colons). Example: `thumbsup`, `heart`, `rocket`.

## Resources

The Slack MCP Server exposes two special directory resources for easy access to workspace metadata:
Expand Down Expand Up @@ -135,7 +145,7 @@ Fetches a CSV directory of all users in the workspace.
| `SLACK_MCP_SERVER_CA` | No | `nil` | Path to CA certificate |
| `SLACK_MCP_SERVER_CA_TOOLKIT` | No | `nil` | Inject HTTPToolkit CA certificate to root trust-store for MitM debugging |
| `SLACK_MCP_SERVER_CA_INSECURE` | No | `false` | Trust all insecure requests (NOT RECOMMENDED) |
| `SLACK_MCP_ADD_MESSAGE_TOOL` | No | `nil` | Enable message posting via `conversations_add_message` by setting it to true for all channels, a comma-separated list of channel IDs to whitelist specific channels, or use `!` before a channel ID to allow all except specified ones, while an empty value disables posting by default. |
| `SLACK_MCP_ADD_MESSAGE_TOOL` | No | `nil` | Enable message posting via `conversations_add_message` and emoji reactions via `reactions_add` by setting it to true for all channels, a comma-separated list of channel IDs to whitelist specific channels, or use `!` before a channel ID to allow all except specified ones, while an empty value disables these tools by default. |
| `SLACK_MCP_ADD_MESSAGE_MARK` | No | `nil` | When the `conversations_add_message` tool is enabled, any new message sent will automatically be marked as read. |
| `SLACK_MCP_ADD_MESSAGE_UNFURLING` | No | `nil` | Enable to let Slack unfurl posted links or set comma-separated list of domains e.g. `github.com,slack.com` to whitelist unfurling only for them. If text contains whitelisted and unknown domain unfurling will be disabled for security reasons. |
| `SLACK_MCP_USERS_CACHE` | No | `~/Library/Caches/slack-mcp-server/users_cache.json` (macOS)<br>`~/.cache/slack-mcp-server/users_cache.json` (Linux)<br>`%LocalAppData%/slack-mcp-server/users_cache.json` (Windows) | Path to the users cache file. Used to cache Slack user information to avoid repeated API calls on startup. |
Expand Down
115 changes: 107 additions & 8 deletions pkg/handler/conversations.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ type addMessageParams struct {
contentType string
}

type addReactionParams struct {
channel string
timestamp string
emoji string
}

type ConversationsHandler struct {
apiProvider *provider.ApiProvider
logger *zap.Logger
Expand Down Expand Up @@ -155,6 +161,12 @@ func (ch *ConversationsHandler) UsersResource(ctx context.Context, request mcp.R
func (ch *ConversationsHandler) ConversationsAddMessageHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ConversationsAddMessageHandler called", zap.Any("params", request.Params))

// provider readiness
if ready, err := ch.apiProvider.IsReady(); !ready {
ch.logger.Error("API provider not ready", zap.Error(err))
return nil, err
}

params, err := ch.parseParamsToolAddMessage(request)
if err != nil {
ch.logger.Error("Failed to parse add-message params", zap.Error(err))
Expand Down Expand Up @@ -230,6 +242,42 @@ func (ch *ConversationsHandler) ConversationsAddMessageHandler(ctx context.Conte
return marshalMessagesToCSV(messages)
}

// ReactionsAddHandler adds an emoji reaction to a message
func (ch *ConversationsHandler) ReactionsAddHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ReactionsAddHandler called", zap.Any("params", request.Params))

// provider readiness
if ready, err := ch.apiProvider.IsReady(); !ready {
ch.logger.Error("API provider not ready", zap.Error(err))
return nil, err
}

params, err := ch.parseParamsToolAddReaction(request)
if err != nil {
ch.logger.Error("Failed to parse add-reaction params", zap.Error(err))
return nil, err
}

itemRef := slack.ItemRef{
Channel: params.channel,
Timestamp: params.timestamp,
}

ch.logger.Debug("Adding reaction to Slack message",
zap.String("channel", params.channel),
zap.String("timestamp", params.timestamp),
zap.String("emoji", params.emoji),
)

err = ch.apiProvider.Slack().AddReactionContext(ctx, params.emoji, itemRef)
if err != nil {
ch.logger.Error("Slack AddReactionContext failed", zap.Error(err))
return nil, err
}

return mcp.NewToolResultText(fmt.Sprintf("Successfully added :%s: reaction to message %s in channel %s", params.emoji, params.timestamp, params.channel)), nil
}

// ConversationsHistoryHandler streams conversation history as CSV
func (ch *ConversationsHandler) ConversationsHistoryHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ch.logger.Debug("ConversationsHistoryHandler called", zap.Any("params", request.Params))
Expand Down Expand Up @@ -363,6 +411,18 @@ func isChannelAllowed(channel string) bool {
return !isNegated
}

func (ch *ConversationsHandler) resolveChannelID(channel string) (string, error) {
if !strings.HasPrefix(channel, "#") && !strings.HasPrefix(channel, "@") {
return channel, nil
}
channelsMaps := ch.apiProvider.ProvideChannelsMaps()
chn, ok := channelsMaps.ChannelsInv[channel]
if !ok {
return "", fmt.Errorf("channel %q not found", channel)
}
return channelsMaps.Channels[chn].ID, nil
}

func (ch *ConversationsHandler) convertMessagesFromHistory(slackMessages []slack.Message, channel string, includeActivity bool) []Message {
usersMap := ch.apiProvider.ProvideUsersMap()
var messages []Message
Expand Down Expand Up @@ -552,14 +612,10 @@ func (ch *ConversationsHandler) parseParamsToolAddMessage(request mcp.CallToolRe
ch.logger.Error("channel_id missing in add-message params")
return nil, errors.New("channel_id must be a string")
}
if strings.HasPrefix(channel, "#") || strings.HasPrefix(channel, "@") {
channelsMaps := ch.apiProvider.ProvideChannelsMaps()
chn, ok := channelsMaps.ChannelsInv[channel]
if !ok {
ch.logger.Error("Channel not found", zap.String("channel", channel))
return nil, fmt.Errorf("channel %q not found", channel)
}
channel = channelsMaps.Channels[chn].ID
channel, err := ch.resolveChannelID(channel)
if err != nil {
ch.logger.Error("Channel not found", zap.String("channel", channel), zap.Error(err))
return nil, err
}
if !isChannelAllowed(channel) {
ch.logger.Warn("Add-message tool not allowed for channel", zap.String("channel", channel), zap.String("policy", toolConfig))
Expand Down Expand Up @@ -592,6 +648,49 @@ func (ch *ConversationsHandler) parseParamsToolAddMessage(request mcp.CallToolRe
}, nil
}

func (ch *ConversationsHandler) parseParamsToolAddReaction(request mcp.CallToolRequest) (*addReactionParams, error) {
toolConfig := os.Getenv("SLACK_MCP_ADD_MESSAGE_TOOL")
if toolConfig == "" {
ch.logger.Error("Reactions tool disabled by default")
return nil, errors.New(
"by default, the reactions_add tool is disabled to guard Slack workspaces against accidental spamming. " +
"To enable it, set the SLACK_MCP_ADD_MESSAGE_TOOL environment variable to true, 1, or comma separated list of channels " +
"to limit where the MCP can add reactions, e.g. 'SLACK_MCP_ADD_MESSAGE_TOOL=C1234567890,D0987654321', 'SLACK_MCP_ADD_MESSAGE_TOOL=!C1234567890' " +
"to enable all except one or 'SLACK_MCP_ADD_MESSAGE_TOOL=true' for all channels and DMs",
)
}

channel := request.GetString("channel_id", "")
if channel == "" {
return nil, errors.New("channel_id is required")
}
channel, err := ch.resolveChannelID(channel)
if err != nil {
ch.logger.Error("Channel not found", zap.String("channel", channel), zap.Error(err))
return nil, err
}
if !isChannelAllowed(channel) {
ch.logger.Warn("Reactions tool not allowed for channel", zap.String("channel", channel), zap.String("policy", toolConfig))
return nil, fmt.Errorf("reactions_add tool is not allowed for channel %q, applied policy: %s", channel, toolConfig)
}

timestamp := request.GetString("timestamp", "")
if timestamp == "" {
return nil, errors.New("timestamp is required")
}

emoji := strings.Trim(request.GetString("emoji", ""), ":")
if emoji == "" {
return nil, errors.New("emoji is required")
}

return &addReactionParams{
channel: channel,
timestamp: timestamp,
emoji: emoji,
}, nil
}

func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (*searchParams, error) {
rawQuery := strings.TrimSpace(req.GetString("search_query", ""))
freeText, filters := splitQuery(rawQuery)
Expand Down
5 changes: 5 additions & 0 deletions pkg/provider/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type SlackAPI interface {
GetUsersInfo(users ...string) (*[]slack.User, error)
PostMessageContext(ctx context.Context, channel string, options ...slack.MsgOption) (string, string, error)
MarkConversationContext(ctx context.Context, channel, ts string) error
AddReactionContext(ctx context.Context, name string, item slack.ItemRef) error

// Used to get messages
GetConversationHistoryContext(ctx context.Context, params *slack.GetConversationHistoryParameters) (*slack.GetConversationHistoryResponse, error)
Expand Down Expand Up @@ -284,6 +285,10 @@ func (c *MCPSlackClient) PostMessageContext(ctx context.Context, channelID strin
return c.slackClient.PostMessageContext(ctx, channelID, options...)
}

func (c *MCPSlackClient) AddReactionContext(ctx context.Context, name string, item slack.ItemRef) error {
return c.slackClient.AddReactionContext(ctx, name, item)
}

func (c *MCPSlackClient) ClientUserBoot(ctx context.Context) (*edge.ClientUserBootResponse, error) {
return c.edgeClient.ClientUserBoot(ctx)
}
Expand Down
16 changes: 16 additions & 0 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@ func NewMCPServer(provider *provider.ApiProvider, logger *zap.Logger) *MCPServer
),
), conversationsHandler.ConversationsAddMessageHandler)

s.AddTool(mcp.NewTool("reactions_add",
mcp.WithDescription("Add an emoji reaction to a message in a public channel, private channel, or direct message (DM, or IM) conversation."),
mcp.WithString("channel_id",
mcp.Required(),
mcp.Description("ID of the channel in format Cxxxxxxxxxx or its name starting with #... or @... aka #general or @username_dm."),
),
mcp.WithString("timestamp",
mcp.Required(),
mcp.Description("Timestamp of the message to add reaction to, in format 1234567890.123456."),
),
mcp.WithString("emoji",
mcp.Required(),
mcp.Description("The name of the emoji to add as a reaction (without colons). Example: 'thumbsup', 'heart', 'rocket'."),
),
), conversationsHandler.ReactionsAddHandler)

conversationsSearchTool := mcp.NewTool("conversations_search_messages",
mcp.WithDescription("Search messages in a public channel, private channel, or direct message (DM, or IM) conversation using filters. All filters are optional, if not provided then search_query is required."),
mcp.WithString("search_query",
Expand Down