From 9f4275ef58a9474fff8d5010eddb5200041eff02 Mon Sep 17 00:00:00 2001 From: Abid Mahdi Date: Tue, 9 Jun 2026 11:54:43 +0100 Subject: [PATCH 1/2] feat: add slack_add_reaction and slack_remove_reaction tools Closes #31. Adds a lightweight local MCP server (src/reactions.ts) that wraps reactions.add / reactions.remove from the Slack Web API. The remote MCP server at mcp.slack.com does not expose reaction tools; this server runs as a local subprocess alongside the remote one. Tools: slack_add_reaction, slack_remove_reaction Params: channel_id, message_ts, emoji_name Scope required: reactions:write Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + .mcp.json | 7 ++++ CLAUDE.md | 25 ++++++++++++ README.md | 51 +++++++++++++++++++++++ package.json | 15 +++++++ skills/slack-messaging/SKILL.md | 2 +- src/reactions.ts | 72 +++++++++++++++++++++++++++++++++ 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 package.json create mode 100644 src/reactions.ts diff --git a/.gitignore b/.gitignore index 4c5f206..fee63cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .claude/ +node_modules/ diff --git a/.mcp.json b/.mcp.json index cc99b48..7b4edf3 100644 --- a/.mcp.json +++ b/.mcp.json @@ -7,6 +7,13 @@ "clientId": "1601185624273.8899143856786", "callbackPort": 3118 } + }, + "slack-reactions": { + "command": "npx", + "args": ["tsx", "./src/reactions.ts"], + "env": { + "SLACK_BOT_TOKEN": "" + } } } } diff --git a/CLAUDE.md b/CLAUDE.md index 7621b89..71c19a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,3 +14,28 @@ This plugin integrates Slack with Claude Code, providing tools to search, read, - **slack-messaging** — Guidance for composing well-formatted Slack messages using standard markdown - **slack-search** — Guidance for effectively searching Slack to find messages, files, channels, and people + +## Emoji Reactions + +The `slack-reactions` MCP server adds `slack_add_reaction` and `slack_remove_reaction` tools. It runs as a local subprocess and calls the Slack Web API directly. + +### Tools + +- **slack_add_reaction** — Add an emoji reaction to a message (`channel_id`, `message_ts`, `emoji_name`) +- **slack_remove_reaction** — Remove an emoji reaction from a message (`channel_id`, `message_ts`, `emoji_name`) + +### Setup + +Requires a Bot User OAuth Token (`xoxb-...`) with the `reactions:write` scope. Set it in `.mcp.json`: + +```json +"slack-reactions": { + "command": "npx", + "args": ["tsx", "./src/reactions.ts"], + "env": { + "SLACK_BOT_TOKEN": "xoxb-your-token-here" + } +} +``` + +Install dependencies first: `npm install` diff --git a/README.md b/README.md index 7c4992a..63f6061 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,57 @@ Add the following configuration to connect to the remote Slack MCP server: Save the configuration. You will also see a connect button once added. Click that to authenticate into your Slack Workspace. +## Emoji Reactions + +The remote Slack MCP server does not support emoji reactions. This repo ships a lightweight local server (`src/reactions.ts`) that adds `slack_add_reaction` and `slack_remove_reaction` by calling the Slack Web API directly. + +### Slack App Setup + +1. Go to [api.slack.com/apps](https://api.slack.com/apps), open your app (or create one from scratch). +2. Under **OAuth & Permissions → Bot Token Scopes**, add `reactions:write`. +3. Reinstall the app to your workspace and copy the **Bot User OAuth Token** (`xoxb-...`). + +### Configuration + +Install the local dependencies: + +```bash +npm install +``` + +Add your bot token to the `slack-reactions` entry in `.mcp.json`: + +```json +{ + "mcpServers": { + "slack": { + "type": "http", + "url": "https://mcp.slack.com/mcp", + "oauth": { + "clientId": "1601185624273.8899143856786", + "callbackPort": 3118 + } + }, + "slack-reactions": { + "command": "npx", + "args": ["tsx", "./src/reactions.ts"], + "env": { + "SLACK_BOT_TOKEN": "xoxb-your-token-here" + } + } + } +} +``` + +### Tools + +| Tool | Parameters | Description | +|------|-----------|-------------| +| `slack_add_reaction` | `channel_id`, `message_ts`, `emoji_name` | Add an emoji reaction to a message | +| `slack_remove_reaction` | `channel_id`, `message_ts`, `emoji_name` | Remove an emoji reaction from a message | + +`emoji_name` is the emoji name without colons — e.g. `thumbsup`, `white_check_mark`, `eyes`. + ## Usage Examples Once configured, you can interact with Slack through your AI assistant using natural language: diff --git a/package.json b/package.json new file mode 100644 index 0000000..251aa20 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "slack-mcp-plugin", + "version": "1.0.0", + "type": "module", + "private": true, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.28.0", + "@slack/web-api": "^7.0.0", + "tsx": "^4.19.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.8.0" + } +} diff --git a/skills/slack-messaging/SKILL.md b/skills/slack-messaging/SKILL.md index 103451f..e82f3d5 100644 --- a/skills/slack-messaging/SKILL.md +++ b/skills/slack-messaging/SKILL.md @@ -49,5 +49,5 @@ Not supported: ## Tone and Audience - Match the tone to the channel — `#general` is usually more formal than `#random`. -- Use emoji reactions instead of reply messages for simple acknowledgments (though note: the MCP tools can't add reactions, so suggest the user do this manually if appropriate). +- Use emoji reactions instead of reply messages for simple acknowledgments. Use `slack_add_reaction` if the `slack-reactions` server is configured; otherwise suggest the user add the reaction manually. - When writing announcements, use a clear structure: context, key info, call to action. diff --git a/src/reactions.ts b/src/reactions.ts new file mode 100644 index 0000000..6f3c270 --- /dev/null +++ b/src/reactions.ts @@ -0,0 +1,72 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { WebClient } from '@slack/web-api' + +const token = process.env.SLACK_BOT_TOKEN +if (!token?.startsWith('xoxb-')) { + console.error('[slack-reactions] SLACK_BOT_TOKEN is missing or invalid (must start with xoxb-)') + process.exit(1) +} + +const slack = new WebClient(token) + +const TOOLS = [ + { + name: 'slack_add_reaction', + description: 'Add an emoji reaction to a Slack message', + inputSchema: { + type: 'object' as const, + properties: { + channel_id: { type: 'string', description: 'ID of the channel containing the message' }, + message_ts: { type: 'string', description: 'Timestamp of the message to react to' }, + emoji_name: { type: 'string', description: 'Emoji name without colons (e.g. thumbsup, white_check_mark, eyes)' }, + }, + required: ['channel_id', 'message_ts', 'emoji_name'], + }, + }, + { + name: 'slack_remove_reaction', + description: 'Remove an emoji reaction from a Slack message', + inputSchema: { + type: 'object' as const, + properties: { + channel_id: { type: 'string', description: 'ID of the channel containing the message' }, + message_ts: { type: 'string', description: 'Timestamp of the message' }, + emoji_name: { type: 'string', description: 'Emoji name without colons (e.g. thumbsup, white_check_mark, eyes)' }, + }, + required: ['channel_id', 'message_ts', 'emoji_name'], + }, + }, +] + +const server = new Server( + { name: 'slack-reactions', version: '1.0.0' }, + { capabilities: { tools: {} } }, +) + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })) + +server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params + const a = args as Record + + try { + switch (name) { + case 'slack_add_reaction': + await slack.reactions.add({ channel: a.channel_id, timestamp: a.message_ts, name: a.emoji_name }) + return { content: [{ type: 'text', text: 'reaction added' }] } + + case 'slack_remove_reaction': + await slack.reactions.remove({ channel: a.channel_id, timestamp: a.message_ts, name: a.emoji_name }) + return { content: [{ type: 'text', text: 'reaction removed' }] } + + default: + throw new Error(`unknown tool: ${name}`) + } + } catch (err) { + return { content: [{ type: 'text', text: `error: ${(err as Error).message}` }], isError: true } + } +}) + +await server.connect(new StdioServerTransport()) From 853b4f106975f18ab01bf0a573488782403150a9 Mon Sep 17 00:00:00 2001 From: Abid Mahdi Date: Tue, 9 Jun 2026 12:26:57 +0100 Subject: [PATCH 2/2] test: add vitest test suite for reactions server Refactors src/reactions.ts to export TOOLS, handleToolCall, and createServer so they can be unit-tested without starting the server. Startup code (env validation + server.connect) is guarded behind an import.meta.url check and only runs when the file is executed directly. Adds tests/reactions.test.ts: 14 tests covering tool definitions, slack_add_reaction, slack_remove_reaction, and unknown tool handling. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 6 +- src/reactions.ts | 64 +++++++++++------ tests/reactions.test.ts | 152 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 tests/reactions.test.ts diff --git a/package.json b/package.json index 251aa20..e201ae2 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "type": "module", "private": true, + "scripts": { + "test": "vitest run" + }, "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", "@slack/web-api": "^7.0.0", @@ -10,6 +13,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.8.0" + "typescript": "^5.8.0", + "vitest": "^4.1.0" } } diff --git a/src/reactions.ts b/src/reactions.ts index 6f3c270..ed6cc92 100644 --- a/src/reactions.ts +++ b/src/reactions.ts @@ -2,16 +2,16 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { WebClient } from '@slack/web-api' +import { fileURLToPath } from 'node:url' -const token = process.env.SLACK_BOT_TOKEN -if (!token?.startsWith('xoxb-')) { - console.error('[slack-reactions] SLACK_BOT_TOKEN is missing or invalid (must start with xoxb-)') - process.exit(1) +interface SlackReactionsClient { + reactions: { + add: (params: { channel: string; timestamp: string; name: string }) => Promise + remove: (params: { channel: string; timestamp: string; name: string }) => Promise + } } -const slack = new WebClient(token) - -const TOOLS = [ +export const TOOLS = [ { name: 'slack_add_reaction', description: 'Add an emoji reaction to a Slack message', @@ -40,25 +40,19 @@ const TOOLS = [ }, ] -const server = new Server( - { name: 'slack-reactions', version: '1.0.0' }, - { capabilities: { tools: {} } }, -) - -server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })) - -server.setRequestHandler(CallToolRequestSchema, async (req) => { - const { name, arguments: args } = req.params - const a = args as Record - +export async function handleToolCall( + slack: SlackReactionsClient, + name: string, + args: Record, +): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { try { switch (name) { case 'slack_add_reaction': - await slack.reactions.add({ channel: a.channel_id, timestamp: a.message_ts, name: a.emoji_name }) + await slack.reactions.add({ channel: args.channel_id, timestamp: args.message_ts, name: args.emoji_name }) return { content: [{ type: 'text', text: 'reaction added' }] } case 'slack_remove_reaction': - await slack.reactions.remove({ channel: a.channel_id, timestamp: a.message_ts, name: a.emoji_name }) + await slack.reactions.remove({ channel: args.channel_id, timestamp: args.message_ts, name: args.emoji_name }) return { content: [{ type: 'text', text: 'reaction removed' }] } default: @@ -67,6 +61,32 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { } catch (err) { return { content: [{ type: 'text', text: `error: ${(err as Error).message}` }], isError: true } } -}) +} + +export function createServer(slack: SlackReactionsClient): Server { + const server = new Server( + { name: 'slack-reactions', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })) + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params + return handleToolCall(slack, name, args as Record) + }) + + return server +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const token = process.env.SLACK_BOT_TOKEN + if (!token?.startsWith('xoxb-')) { + console.error('[slack-reactions] SLACK_BOT_TOKEN is missing or invalid (must start with xoxb-)') + process.exit(1) + } -await server.connect(new StdioServerTransport()) + const slack = new WebClient(token) + const server = createServer(slack) + await server.connect(new StdioServerTransport()) +} diff --git a/tests/reactions.test.ts b/tests/reactions.test.ts new file mode 100644 index 0000000..fff7865 --- /dev/null +++ b/tests/reactions.test.ts @@ -0,0 +1,152 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { TOOLS, handleToolCall } from '../src/reactions' + +function createMockSlack() { + return { + reactions: { + add: vi.fn(() => Promise.resolve({ ok: true })), + remove: vi.fn(() => Promise.resolve({ ok: true })), + }, + } +} + +describe('TOOLS definitions', () => { + test('exports slack_add_reaction and slack_remove_reaction', () => { + const names = TOOLS.map(t => t.name) + expect(names).toContain('slack_add_reaction') + expect(names).toContain('slack_remove_reaction') + }) + + test('slack_add_reaction requires channel_id, message_ts, emoji_name', () => { + const tool = TOOLS.find(t => t.name === 'slack_add_reaction')! + expect(tool.inputSchema.required).toEqual(['channel_id', 'message_ts', 'emoji_name']) + }) + + test('slack_remove_reaction requires channel_id, message_ts, emoji_name', () => { + const tool = TOOLS.find(t => t.name === 'slack_remove_reaction')! + expect(tool.inputSchema.required).toEqual(['channel_id', 'message_ts', 'emoji_name']) + }) + + test('emoji_name description mentions no colons', () => { + const tool = TOOLS.find(t => t.name === 'slack_add_reaction')! + expect(tool.inputSchema.properties.emoji_name.description).toContain('without colons') + }) +}) + +describe('handleToolCall - slack_add_reaction', () => { + let mockSlack: ReturnType + + beforeEach(() => { + mockSlack = createMockSlack() + }) + + test('calls reactions.add with correct params', async () => { + await handleToolCall(mockSlack, 'slack_add_reaction', { + channel_id: 'C123', + message_ts: '1234.5678', + emoji_name: 'thumbsup', + }) + expect(mockSlack.reactions.add).toHaveBeenCalledWith({ + channel: 'C123', + timestamp: '1234.5678', + name: 'thumbsup', + }) + }) + + test('returns success response', async () => { + const result = await handleToolCall(mockSlack, 'slack_add_reaction', { + channel_id: 'C123', + message_ts: '1234.5678', + emoji_name: 'thumbsup', + }) + expect(result.content[0].text).toBe('reaction added') + expect(result.isError).toBeUndefined() + }) + + test('returns error response on API failure', async () => { + mockSlack.reactions.add.mockRejectedValueOnce(new Error('already_reacted')) + const result = await handleToolCall(mockSlack, 'slack_add_reaction', { + channel_id: 'C123', + message_ts: '1234.5678', + emoji_name: 'thumbsup', + }) + expect(result.content[0].text).toContain('already_reacted') + expect(result.isError).toBe(true) + }) + + test('does not call reactions.remove', async () => { + await handleToolCall(mockSlack, 'slack_add_reaction', { + channel_id: 'C123', + message_ts: '1234.5678', + emoji_name: 'thumbsup', + }) + expect(mockSlack.reactions.remove).not.toHaveBeenCalled() + }) +}) + +describe('handleToolCall - slack_remove_reaction', () => { + let mockSlack: ReturnType + + beforeEach(() => { + mockSlack = createMockSlack() + }) + + test('calls reactions.remove with correct params', async () => { + await handleToolCall(mockSlack, 'slack_remove_reaction', { + channel_id: 'C456', + message_ts: '9999.0001', + emoji_name: 'eyes', + }) + expect(mockSlack.reactions.remove).toHaveBeenCalledWith({ + channel: 'C456', + timestamp: '9999.0001', + name: 'eyes', + }) + }) + + test('returns success response', async () => { + const result = await handleToolCall(mockSlack, 'slack_remove_reaction', { + channel_id: 'C456', + message_ts: '9999.0001', + emoji_name: 'eyes', + }) + expect(result.content[0].text).toBe('reaction removed') + expect(result.isError).toBeUndefined() + }) + + test('returns error response on API failure', async () => { + mockSlack.reactions.remove.mockRejectedValueOnce(new Error('no_reaction')) + const result = await handleToolCall(mockSlack, 'slack_remove_reaction', { + channel_id: 'C456', + message_ts: '9999.0001', + emoji_name: 'eyes', + }) + expect(result.content[0].text).toContain('no_reaction') + expect(result.isError).toBe(true) + }) + + test('does not call reactions.add', async () => { + await handleToolCall(mockSlack, 'slack_remove_reaction', { + channel_id: 'C456', + message_ts: '9999.0001', + emoji_name: 'eyes', + }) + expect(mockSlack.reactions.add).not.toHaveBeenCalled() + }) +}) + +describe('handleToolCall - unknown tool', () => { + test('returns error response', async () => { + const mockSlack = createMockSlack() + const result = await handleToolCall(mockSlack, 'slack_unknown_tool', {}) + expect(result.content[0].text).toContain('unknown tool') + expect(result.isError).toBe(true) + }) + + test('does not call any Slack API', async () => { + const mockSlack = createMockSlack() + await handleToolCall(mockSlack, 'slack_unknown_tool', {}) + expect(mockSlack.reactions.add).not.toHaveBeenCalled() + expect(mockSlack.reactions.remove).not.toHaveBeenCalled() + }) +})