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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.claude/
node_modules/
7 changes: 7 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
"clientId": "1601185624273.8899143856786",
"callbackPort": 3118
}
},
"slack-reactions": {
"command": "npx",
"args": ["tsx", "./src/reactions.ts"],
"env": {
"SLACK_BOT_TOKEN": ""
}
}
}
}
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion skills/slack-messaging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
92 changes: 92 additions & 0 deletions src/reactions.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>
remove: (params: { channel: string; timestamp: string; name: string }) => Promise<unknown>
}
}

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<string, string>,
): 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<string, string>)
})

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())
}
152 changes: 152 additions & 0 deletions tests/reactions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createMockSlack>

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<typeof createMockSlack>

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()
})
})