diff --git a/README.md b/README.md
index 6debb1f..2b139ea 100644
--- a/README.md
+++ b/README.md
@@ -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:
@@ -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)
`~/.cache/slack-mcp-server/users_cache.json` (Linux)
`%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. |
diff --git a/pkg/handler/conversations.go b/pkg/handler/conversations.go
index 3a06387..0c3060c 100644
--- a/pkg/handler/conversations.go
+++ b/pkg/handler/conversations.go
@@ -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
@@ -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))
@@ -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))
@@ -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
@@ -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))
@@ -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)
diff --git a/pkg/provider/api.go b/pkg/provider/api.go
index f67f1b9..251eccc 100644
--- a/pkg/provider/api.go
+++ b/pkg/provider/api.go
@@ -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)
@@ -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)
}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 5451c49..01be57d 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -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",