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..e201ae2 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "slack-mcp-plugin", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "test": "vitest run" + }, + "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", + "vitest": "^4.1.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..ed6cc92 --- /dev/null +++ b/src/reactions.ts @@ -0,0 +1,92 @@ +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' + +interface SlackReactionsClient { + reactions: { + add: (params: { channel: string; timestamp: string; name: string }) => Promise + remove: (params: { channel: string; timestamp: string; name: string }) => Promise + } +} + +export 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'], + }, + }, +] + +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: 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: args.channel_id, timestamp: args.message_ts, name: args.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 } + } +} + +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) + } + + 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() + }) +})