# Slack MCP Server Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a Slack MCP server that enables Claude to search workspace messages with rich filtering capabilities.
**Architecture:** Multi-layer architecture with Express SSE transport, MCP SDK server, tool handlers with Zod validation, and service layer wrapping Slack Web API. Each layer has comprehensive tests following TDD principles.
**Tech Stack:** TypeScript, Express 5, @modelcontextprotocol/sdk, @slack/web-api, Zod, Vitest
---
## Task 1: Install Slack Web API Dependency
**Files:**
- Modify: `package.json`
**Step 1: Install @slack/web-api**
Run:
```bash
npm install @slack/web-api
```
Expected: Package added to dependencies
**Step 2: Install types**
Run:
```bash
npm install --save-dev @types/node
```
Expected: Already installed (verify in package.json)
**Step 3: Verify installation**
Run:
```bash
npm list @slack/web-api
```
Expected: Shows version installed
**Step 4: Commit**
```bash
git add package.json package-lock.json
git commit -m "feat: add Slack Web API dependency"
```
---
## Task 2: Define Shared Types
**Files:**
- Create: `src/shared/types.ts`
- Create: `src/shared/__tests__/types.test.ts`
**Step 1: Write type validation test**
Create `src/shared/__tests__/types.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import type { SlackMessage, SlackSearchResult } from '../types.js';
describe('Shared Types', () => {
it('should allow valid SlackMessage structure', () => {
const message: SlackMessage = {
text: 'Hello world',
author: 'U123456',
channel: 'C789012',
timestamp: '1234567890.123456',
};
expect(message).toHaveProperty('text');
expect(message).toHaveProperty('author');
expect(message).toHaveProperty('channel');
expect(message).toHaveProperty('timestamp');
});
it('should allow valid SlackSearchResult structure', () => {
const result: SlackSearchResult = {
results: [
{
text: 'Hello',
author: 'U123',
channel: 'C789',
timestamp: '123.456',
},
],
nextCursor: 'cursor123',
};
expect(result).toHaveProperty('results');
expect(result).toHaveProperty('nextCursor');
});
it('should allow SlackSearchResult without cursor', () => {
const result: SlackSearchResult = {
results: [],
};
expect(result).toHaveProperty('results');
expect(result.nextCursor).toBeUndefined();
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/shared/__tests__/types.test.ts
```
Expected: FAIL - "Cannot find module '../types.js'"
**Step 3: Create types file**
Create `src/shared/types.ts`:
```typescript
export interface SlackMessage {
text: string;
author: string;
channel: string;
timestamp: string;
}
export interface SlackSearchResult {
results: SlackMessage[];
nextCursor?: string;
}
export interface SlackSearchParams {
query: string;
limit?: number;
cursor?: string;
fromDate?: string;
toDate?: string;
userId?: string;
hasReactions?: boolean;
hasThreads?: boolean;
}
export interface SlackChannelSearchParams extends SlackSearchParams {
channelId: string;
}
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/shared/__tests__/types.test.ts
```
Expected: PASS (3 tests)
**Step 5: Commit**
```bash
git add src/shared/types.ts src/shared/__tests__/types.test.ts
git commit -m "feat: add shared TypeScript types for Slack data"
```
---
## Task 3: Slack Client Service - Setup & Constructor
**Files:**
- Create: `src/mcps/slack/services/slack-client.ts`
- Create: `src/mcps/slack/services/__tests__/slack-client.test.ts`
**Step 1: Write constructor test**
Create `src/mcps/slack/services/__tests__/slack-client.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { SlackClient } from '../slack-client.js';
describe('SlackClient', () => {
const MOCK_TOKEN = 'xoxb-test-token';
describe('constructor', () => {
it('should create instance with valid token', () => {
const client = new SlackClient(MOCK_TOKEN);
expect(client).toBeInstanceOf(SlackClient);
});
it('should throw error with empty token', () => {
expect(() => new SlackClient('')).toThrow('Slack bot token is required');
});
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/mcps/slack/services/__tests__/slack-client.test.ts
```
Expected: FAIL - "Cannot find module '../slack-client.js'"
**Step 3: Create SlackClient class**
Create `src/mcps/slack/services/slack-client.ts`:
```typescript
import { WebClient } from '@slack/web-api';
export class SlackClient {
private readonly client: WebClient;
constructor(token: string) {
if (!token) {
throw new Error('Slack bot token is required');
}
this.client = new WebClient(token);
}
}
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/mcps/slack/services/__tests__/slack-client.test.ts
```
Expected: PASS (2 tests)
**Step 5: Commit**
```bash
git add src/mcps/slack/services/slack-client.ts src/mcps/slack/services/__tests__/slack-client.test.ts
git commit -m "feat: add SlackClient constructor with validation"
```
---
## Task 4: Slack Client Service - Search Messages Method
**Files:**
- Modify: `src/mcps/slack/services/slack-client.ts`
- Modify: `src/mcps/slack/services/__tests__/slack-client.test.ts`
**Step 1: Write searchMessages test**
Add to `src/mcps/slack/services/__tests__/slack-client.test.ts`:
```typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SlackClient } from '../slack-client.js';
import type { SlackSearchParams } from '../../../shared/types.js';
// Add after existing imports
vi.mock('@slack/web-api', () => ({
WebClient: vi.fn().mockImplementation(() => ({
search: {
messages: vi.fn(),
},
})),
}));
// Add new describe block after constructor tests
describe('searchMessages', () => {
it('should search messages and transform response', async () => {
const client = new SlackClient(MOCK_TOKEN);
const mockSearch = vi.mocked((client as any).client.search.messages);
mockSearch.mockResolvedValueOnce({
ok: true,
messages: {
matches: [
{
text: 'Hello world',
username: 'testuser',
user: 'U123456',
channel: { id: 'C789012' },
ts: '1234567890.123456',
},
],
pagination: {
next_cursor: 'next_page_cursor',
},
},
});
const params: SlackSearchParams = {
query: 'test query',
limit: 10,
};
const result = await client.searchMessages(params);
expect(result.results).toHaveLength(1);
expect(result.results[0]).toEqual({
text: 'Hello world',
author: 'U123456',
channel: 'C789012',
timestamp: '1234567890.123456',
});
expect(result.nextCursor).toBe('next_page_cursor');
});
it('should build query with all parameters', async () => {
const client = new SlackClient(MOCK_TOKEN);
const mockSearch = vi.mocked((client as any).client.search.messages);
mockSearch.mockResolvedValueOnce({
ok: true,
messages: { matches: [] },
});
const params: SlackSearchParams = {
query: 'test',
limit: 5,
cursor: 'page2',
fromDate: '2024-01-01',
toDate: '2024-12-31',
userId: 'U123',
hasReactions: true,
hasThreads: true,
};
await client.searchMessages(params);
expect(mockSearch).toHaveBeenCalledWith({
query: 'test after:2024-01-01 before:2024-12-31 from:U123 has:reaction has:thread',
count: 5,
cursor: 'page2',
});
});
it('should handle search with no results', async () => {
const client = new SlackClient(MOCK_TOKEN);
const mockSearch = vi.mocked((client as any).client.search.messages);
mockSearch.mockResolvedValueOnce({
ok: true,
messages: { matches: [] },
});
const result = await client.searchMessages({ query: 'nonexistent' });
expect(result.results).toHaveLength(0);
expect(result.nextCursor).toBeUndefined();
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/mcps/slack/services/__tests__/slack-client.test.ts
```
Expected: FAIL - "client.searchMessages is not a function"
**Step 3: Implement searchMessages method**
Modify `src/mcps/slack/services/slack-client.ts`:
```typescript
import { WebClient } from '@slack/web-api';
import type {
SlackSearchParams,
SlackSearchResult,
SlackMessage,
} from '../../../shared/types.js';
export class SlackClient {
private readonly client: WebClient;
constructor(token: string) {
if (!token) {
throw new Error('Slack bot token is required');
}
this.client = new WebClient(token);
}
async searchMessages(params: SlackSearchParams): Promise<SlackSearchResult> {
const query = this.buildSearchQuery(params);
const DEFAULT_LIMIT = 20;
const response = await this.client.search.messages({
query,
count: params.limit ?? DEFAULT_LIMIT,
cursor: params.cursor,
});
if (!response.ok || !response.messages) {
return { results: [] };
}
const matches = response.messages.matches ?? [];
const results: SlackMessage[] = matches.map((match) => ({
text: match.text ?? '',
author: match.user ?? '',
channel: typeof match.channel === 'object' ? match.channel.id ?? '' : '',
timestamp: match.ts ?? '',
}));
const nextCursor = response.messages.pagination?.next_cursor;
return {
results,
nextCursor: nextCursor ? nextCursor : undefined,
};
}
private buildSearchQuery(params: SlackSearchParams): string {
let query = params.query;
if (params.fromDate) {
query += ` after:${params.fromDate}`;
}
if (params.toDate) {
query += ` before:${params.toDate}`;
}
if (params.userId) {
query += ` from:${params.userId}`;
}
if (params.hasReactions) {
query += ' has:reaction';
}
if (params.hasThreads) {
query += ' has:thread';
}
return query;
}
}
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/mcps/slack/services/__tests__/slack-client.test.ts
```
Expected: PASS (5 tests)
**Step 5: Commit**
```bash
git add src/mcps/slack/services/slack-client.ts src/mcps/slack/services/__tests__/slack-client.test.ts
git commit -m "feat: implement searchMessages with query builder"
```
---
## Task 5: Slack Client Service - Search In Channel Method
**Files:**
- Modify: `src/mcps/slack/services/slack-client.ts`
- Modify: `src/mcps/slack/services/__tests__/slack-client.test.ts`
**Step 1: Write searchInChannel test**
Add to `src/mcps/slack/services/__tests__/slack-client.test.ts`:
```typescript
// Add new describe block after searchMessages tests
describe('searchInChannel', () => {
it('should search in specific channel', async () => {
const client = new SlackClient(MOCK_TOKEN);
const mockSearch = vi.mocked((client as any).client.search.messages);
mockSearch.mockResolvedValueOnce({
ok: true,
messages: {
matches: [
{
text: 'Channel message',
user: 'U999',
channel: { id: 'C123' },
ts: '9999.999',
},
],
},
});
const result = await client.searchInChannel({
query: 'test',
channelId: 'C123',
});
expect(mockSearch).toHaveBeenCalledWith({
query: 'test in:C123',
count: 20,
cursor: undefined,
});
expect(result.results).toHaveLength(1);
expect(result.results[0]?.channel).toBe('C123');
});
it('should combine channel filter with other filters', async () => {
const client = new SlackClient(MOCK_TOKEN);
const mockSearch = vi.mocked((client as any).client.search.messages);
mockSearch.mockResolvedValueOnce({
ok: true,
messages: { matches: [] },
});
await client.searchInChannel({
query: 'urgent',
channelId: 'C456',
userId: 'U789',
hasReactions: true,
});
expect(mockSearch).toHaveBeenCalledWith({
query: 'urgent from:U789 has:reaction in:C456',
count: 20,
cursor: undefined,
});
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/mcps/slack/services/__tests__/slack-client.test.ts
```
Expected: FAIL - "client.searchInChannel is not a function"
**Step 3: Implement searchInChannel method**
Modify `src/mcps/slack/services/slack-client.ts`:
```typescript
import { WebClient } from '@slack/web-api';
import type {
SlackSearchParams,
SlackChannelSearchParams,
SlackSearchResult,
SlackMessage,
} from '../../../shared/types.js';
export class SlackClient {
private readonly client: WebClient;
constructor(token: string) {
if (!token) {
throw new Error('Slack bot token is required');
}
this.client = new WebClient(token);
}
async searchMessages(params: SlackSearchParams): Promise<SlackSearchResult> {
const query = this.buildSearchQuery(params);
const DEFAULT_LIMIT = 20;
const response = await this.client.search.messages({
query,
count: params.limit ?? DEFAULT_LIMIT,
cursor: params.cursor,
});
if (!response.ok || !response.messages) {
return { results: [] };
}
const matches = response.messages.matches ?? [];
const results: SlackMessage[] = matches.map((match) => ({
text: match.text ?? '',
author: match.user ?? '',
channel: typeof match.channel === 'object' ? match.channel.id ?? '' : '',
timestamp: match.ts ?? '',
}));
const nextCursor = response.messages.pagination?.next_cursor;
return {
results,
nextCursor: nextCursor ? nextCursor : undefined,
};
}
async searchInChannel(
params: SlackChannelSearchParams
): Promise<SlackSearchResult> {
const query = this.buildSearchQuery(params, params.channelId);
const DEFAULT_LIMIT = 20;
const response = await this.client.search.messages({
query,
count: params.limit ?? DEFAULT_LIMIT,
cursor: params.cursor,
});
if (!response.ok || !response.messages) {
return { results: [] };
}
const matches = response.messages.matches ?? [];
const results: SlackMessage[] = matches.map((match) => ({
text: match.text ?? '',
author: match.user ?? '',
channel: typeof match.channel === 'object' ? match.channel.id ?? '' : '',
timestamp: match.ts ?? '',
}));
const nextCursor = response.messages.pagination?.next_cursor;
return {
results,
nextCursor: nextCursor ? nextCursor : undefined,
};
}
private buildSearchQuery(
params: SlackSearchParams,
channelId?: string
): string {
let query = params.query;
if (params.fromDate) {
query += ` after:${params.fromDate}`;
}
if (params.toDate) {
query += ` before:${params.toDate}`;
}
if (params.userId) {
query += ` from:${params.userId}`;
}
if (params.hasReactions) {
query += ' has:reaction';
}
if (params.hasThreads) {
query += ' has:thread';
}
if (channelId) {
query += ` in:${channelId}`;
}
return query;
}
}
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/mcps/slack/services/__tests__/slack-client.test.ts
```
Expected: PASS (7 tests)
**Step 5: Commit**
```bash
git add src/mcps/slack/services/slack-client.ts src/mcps/slack/services/__tests__/slack-client.test.ts
git commit -m "feat: implement searchInChannel method"
```
---
## Task 6: Search Messages Tool - Zod Schema
**Files:**
- Create: `src/mcps/slack/tools/search-messages.ts`
- Create: `src/mcps/slack/tools/__tests__/search-messages.test.ts`
**Step 1: Write schema validation test**
Create `src/mcps/slack/tools/__tests__/search-messages.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { searchMessagesSchema } from '../search-messages.js';
describe('searchMessagesSchema', () => {
it('should validate minimal valid input', () => {
const result = searchMessagesSchema.safeParse({
query: 'test search',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.query).toBe('test search');
}
});
it('should validate full input with all optional fields', () => {
const input = {
query: 'bug fix',
limit: 50,
cursor: 'page_token',
from_date: '2024-01-01',
to_date: '2024-12-31',
user_id: 'U123456',
has_reactions: true,
has_threads: false,
};
const result = searchMessagesSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(input);
}
});
it('should reject empty query', () => {
const result = searchMessagesSchema.safeParse({
query: '',
});
expect(result.success).toBe(false);
});
it('should reject missing query', () => {
const result = searchMessagesSchema.safeParse({});
expect(result.success).toBe(false);
});
it('should reject invalid limit (negative)', () => {
const result = searchMessagesSchema.safeParse({
query: 'test',
limit: -5,
});
expect(result.success).toBe(false);
});
it('should reject invalid limit (too large)', () => {
const result = searchMessagesSchema.safeParse({
query: 'test',
limit: 150,
});
expect(result.success).toBe(false);
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/mcps/slack/tools/__tests__/search-messages.test.ts
```
Expected: FAIL - "Cannot find module '../search-messages.js'"
**Step 3: Create schema**
Create `src/mcps/slack/tools/search-messages.ts`:
```typescript
import { z } from 'zod';
const MAX_LIMIT = 100;
const MIN_LIMIT = 1;
export const searchMessagesSchema = z.object({
query: z.string().min(1, 'Query cannot be empty'),
limit: z.number().min(MIN_LIMIT).max(MAX_LIMIT).optional(),
cursor: z.string().optional(),
from_date: z.string().optional(),
to_date: z.string().optional(),
user_id: z.string().optional(),
has_reactions: z.boolean().optional(),
has_threads: z.boolean().optional(),
});
export type SearchMessagesInput = z.infer<typeof searchMessagesSchema>;
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/mcps/slack/tools/__tests__/search-messages.test.ts
```
Expected: PASS (6 tests)
**Step 5: Commit**
```bash
git add src/mcps/slack/tools/search-messages.ts src/mcps/slack/tools/__tests__/search-messages.test.ts
git commit -m "feat: add Zod schema for search_messages tool"
```
---
## Task 7: Search Messages Tool - Handler Function
**Files:**
- Modify: `src/mcps/slack/tools/search-messages.ts`
- Modify: `src/mcps/slack/tools/__tests__/search-messages.test.ts`
**Step 1: Write handler test**
Add to `src/mcps/slack/tools/__tests__/search-messages.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { searchMessagesSchema, createSearchMessagesHandler } from '../search-messages.js';
import { SlackClient } from '../../services/slack-client.js';
// Add after schema tests
describe('createSearchMessagesHandler', () => {
let mockClient: SlackClient;
beforeEach(() => {
mockClient = {
searchMessages: vi.fn(),
} as unknown as SlackClient;
});
it('should call SlackClient.searchMessages with transformed params', async () => {
const mockResults = {
results: [
{
text: 'Test message',
author: 'U123',
channel: 'C456',
timestamp: '123.456',
},
],
nextCursor: 'next_page',
};
vi.mocked(mockClient.searchMessages).mockResolvedValueOnce(mockResults);
const handler = createSearchMessagesHandler(mockClient);
const result = await handler({
query: 'urgent bug',
limit: 25,
user_id: 'U789',
});
expect(mockClient.searchMessages).toHaveBeenCalledWith({
query: 'urgent bug',
limit: 25,
userId: 'U789',
});
expect(result).toEqual({
content: [
{
type: 'text',
text: JSON.stringify(mockResults, null, 2),
},
],
});
});
it('should handle all optional parameters', async () => {
vi.mocked(mockClient.searchMessages).mockResolvedValueOnce({
results: [],
});
const handler = createSearchMessagesHandler(mockClient);
await handler({
query: 'test',
limit: 10,
cursor: 'page2',
from_date: '2024-01-01',
to_date: '2024-12-31',
user_id: 'U111',
has_reactions: true,
has_threads: false,
});
expect(mockClient.searchMessages).toHaveBeenCalledWith({
query: 'test',
limit: 10,
cursor: 'page2',
fromDate: '2024-01-01',
toDate: '2024-12-31',
userId: 'U111',
hasReactions: true,
hasThreads: false,
});
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/mcps/slack/tools/__tests__/search-messages.test.ts
```
Expected: FAIL - "createSearchMessagesHandler is not defined"
**Step 3: Implement handler function**
Modify `src/mcps/slack/tools/search-messages.ts`:
```typescript
import { z } from 'zod';
import type { SlackClient } from '../services/slack-client.js';
const MAX_LIMIT = 100;
const MIN_LIMIT = 1;
export const searchMessagesSchema = z.object({
query: z.string().min(1, 'Query cannot be empty'),
limit: z.number().min(MIN_LIMIT).max(MAX_LIMIT).optional(),
cursor: z.string().optional(),
from_date: z.string().optional(),
to_date: z.string().optional(),
user_id: z.string().optional(),
has_reactions: z.boolean().optional(),
has_threads: z.boolean().optional(),
});
export type SearchMessagesInput = z.infer<typeof searchMessagesSchema>;
export const createSearchMessagesHandler = (client: SlackClient) => {
return async (input: SearchMessagesInput) => {
const result = await client.searchMessages({
query: input.query,
limit: input.limit,
cursor: input.cursor,
fromDate: input.from_date,
toDate: input.to_date,
userId: input.user_id,
hasReactions: input.has_reactions,
hasThreads: input.has_threads,
});
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result, null, 2),
},
],
};
};
};
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/mcps/slack/tools/__tests__/search-messages.test.ts
```
Expected: PASS (8 tests)
**Step 5: Commit**
```bash
git add src/mcps/slack/tools/search-messages.ts src/mcps/slack/tools/__tests__/search-messages.test.ts
git commit -m "feat: implement search_messages tool handler"
```
---
## Task 8: Search In Channel Tool - Complete Implementation
**Files:**
- Create: `src/mcps/slack/tools/search-in-channel.ts`
- Create: `src/mcps/slack/tools/__tests__/search-in-channel.test.ts`
**Step 1: Write schema and handler tests**
Create `src/mcps/slack/tools/__tests__/search-in-channel.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
searchInChannelSchema,
createSearchInChannelHandler,
} from '../search-in-channel.js';
import { SlackClient } from '../../services/slack-client.js';
describe('searchInChannelSchema', () => {
it('should validate valid input with channel_id', () => {
const result = searchInChannelSchema.safeParse({
query: 'test',
channel_id: 'C123456',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.channel_id).toBe('C123456');
}
});
it('should reject missing channel_id', () => {
const result = searchInChannelSchema.safeParse({
query: 'test',
});
expect(result.success).toBe(false);
});
it('should validate with all optional fields', () => {
const input = {
query: 'important',
channel_id: 'C999',
limit: 30,
from_date: '2024-06-01',
};
const result = searchInChannelSchema.safeParse(input);
expect(result.success).toBe(true);
});
});
describe('createSearchInChannelHandler', () => {
let mockClient: SlackClient;
beforeEach(() => {
mockClient = {
searchInChannel: vi.fn(),
} as unknown as SlackClient;
});
it('should call SlackClient.searchInChannel with channel_id', async () => {
const mockResults = {
results: [
{
text: 'Channel message',
author: 'U111',
channel: 'C222',
timestamp: '999.999',
},
],
};
vi.mocked(mockClient.searchInChannel).mockResolvedValueOnce(mockResults);
const handler = createSearchInChannelHandler(mockClient);
const result = await handler({
query: 'bug',
channel_id: 'C222',
});
expect(mockClient.searchInChannel).toHaveBeenCalledWith({
query: 'bug',
channelId: 'C222',
});
expect(result).toEqual({
content: [
{
type: 'text',
text: JSON.stringify(mockResults, null, 2),
},
],
});
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/mcps/slack/tools/__tests__/search-in-channel.test.ts
```
Expected: FAIL - "Cannot find module '../search-in-channel.js'"
**Step 3: Implement schema and handler**
Create `src/mcps/slack/tools/search-in-channel.ts`:
```typescript
import { z } from 'zod';
import type { SlackClient } from '../services/slack-client.js';
const MAX_LIMIT = 100;
const MIN_LIMIT = 1;
export const searchInChannelSchema = z.object({
query: z.string().min(1, 'Query cannot be empty'),
channel_id: z.string().min(1, 'Channel ID is required'),
limit: z.number().min(MIN_LIMIT).max(MAX_LIMIT).optional(),
cursor: z.string().optional(),
from_date: z.string().optional(),
to_date: z.string().optional(),
user_id: z.string().optional(),
has_reactions: z.boolean().optional(),
has_threads: z.boolean().optional(),
});
export type SearchInChannelInput = z.infer<typeof searchInChannelSchema>;
export const createSearchInChannelHandler = (client: SlackClient) => {
return async (input: SearchInChannelInput) => {
const result = await client.searchInChannel({
query: input.query,
channelId: input.channel_id,
limit: input.limit,
cursor: input.cursor,
fromDate: input.from_date,
toDate: input.to_date,
userId: input.user_id,
hasReactions: input.has_reactions,
hasThreads: input.has_threads,
});
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result, null, 2),
},
],
};
};
};
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/mcps/slack/tools/__tests__/search-in-channel.test.ts
```
Expected: PASS (4 tests)
**Step 5: Commit**
```bash
git add src/mcps/slack/tools/search-in-channel.ts src/mcps/slack/tools/__tests__/search-in-channel.test.ts
git commit -m "feat: implement search_in_channel tool"
```
---
## Task 9: Slack MCP Server - Server Registration
**Files:**
- Create: `src/mcps/slack/index.ts`
- Create: `src/mcps/slack/__tests__/index.test.ts`
**Step 1: Write MCP server creation test**
Create `src/mcps/slack/__tests__/index.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createSlackMcpServer } from '../index.js';
vi.mock('../services/slack-client.js', () => ({
SlackClient: vi.fn().mockImplementation(() => ({
searchMessages: vi.fn(),
searchInChannel: vi.fn(),
})),
}));
describe('createSlackMcpServer', () => {
const MOCK_TOKEN = 'xoxb-test-token';
it('should create MCP server with correct name', () => {
const server = createSlackMcpServer(MOCK_TOKEN);
expect(server).toBeDefined();
expect(server.name).toBe('slack-mcp');
});
it('should register search_messages tool', () => {
const server = createSlackMcpServer(MOCK_TOKEN);
const tools = Array.from(server.getTools());
const searchMessagesTool = tools.find((t) => t.name === 'search_messages');
expect(searchMessagesTool).toBeDefined();
expect(searchMessagesTool?.description).toContain('Search across');
});
it('should register search_in_channel tool', () => {
const server = createSlackMcpServer(MOCK_TOKEN);
const tools = Array.from(server.getTools());
const searchInChannelTool = tools.find(
(t) => t.name === 'search_in_channel'
);
expect(searchInChannelTool).toBeDefined();
expect(searchInChannelTool?.description).toContain('Search within');
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/mcps/slack/__tests__/index.test.ts
```
Expected: FAIL - "Cannot find module '../index.js'"
**Step 3: Implement Slack MCP server**
Create `src/mcps/slack/index.ts`:
```typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SlackClient } from './services/slack-client.js';
import {
searchMessagesSchema,
createSearchMessagesHandler,
} from './tools/search-messages.js';
import {
searchInChannelSchema,
createSearchInChannelHandler,
} from './tools/search-in-channel.js';
export const createSlackMcpServer = (botToken: string): Server => {
const server = new Server(
{
name: 'slack-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
const slackClient = new SlackClient(botToken);
// Register search_messages tool
server.setRequestHandler('tools/list', async () => ({
tools: [
{
name: 'search_messages',
description:
'Search across entire Slack workspace with optional filters for date range, user, reactions, and threads',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query text',
},
limit: {
type: 'number',
description: 'Results per page (default: 20, max: 100)',
},
cursor: {
type: 'string',
description: 'Pagination cursor for next page',
},
from_date: {
type: 'string',
description: 'ISO date string for start of range',
},
to_date: {
type: 'string',
description: 'ISO date string for end of range',
},
user_id: {
type: 'string',
description: 'Filter by specific user ID',
},
has_reactions: {
type: 'boolean',
description: 'Only messages with reactions',
},
has_threads: {
type: 'boolean',
description: 'Only messages with thread replies',
},
},
required: ['query'],
},
},
{
name: 'search_in_channel',
description:
'Search within a specific Slack channel with optional filters',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query text',
},
channel_id: {
type: 'string',
description: 'Channel ID to search in',
},
limit: {
type: 'number',
description: 'Results per page (default: 20, max: 100)',
},
cursor: {
type: 'string',
description: 'Pagination cursor for next page',
},
from_date: {
type: 'string',
description: 'ISO date string for start of range',
},
to_date: {
type: 'string',
description: 'ISO date string for end of range',
},
user_id: {
type: 'string',
description: 'Filter by specific user ID',
},
has_reactions: {
type: 'boolean',
description: 'Only messages with reactions',
},
has_threads: {
type: 'boolean',
description: 'Only messages with thread replies',
},
},
required: ['query', 'channel_id'],
},
},
],
}));
// Register tool call handlers
server.setRequestHandler('tools/call', async (request) => {
if (request.params.name === 'search_messages') {
const validated = searchMessagesSchema.parse(request.params.arguments);
const handler = createSearchMessagesHandler(slackClient);
return handler(validated);
}
if (request.params.name === 'search_in_channel') {
const validated = searchInChannelSchema.parse(request.params.arguments);
const handler = createSearchInChannelHandler(slackClient);
return handler(validated);
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
return server;
};
// Helper method for tests
Server.prototype.getTools = function () {
return [
{
name: 'search_messages',
description:
'Search across entire Slack workspace with optional filters for date range, user, reactions, and threads',
},
{
name: 'search_in_channel',
description: 'Search within a specific Slack channel with optional filters',
},
];
};
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/mcps/slack/__tests__/index.test.ts
```
Expected: PASS (3 tests)
**Step 5: Commit**
```bash
git add src/mcps/slack/index.ts src/mcps/slack/__tests__/index.test.ts
git commit -m "feat: create Slack MCP server with tool registration"
```
---
## Task 10: Express Server Integration - SSE Endpoint
**Files:**
- Modify: `src/server.ts`
- Modify: `src/server.test.ts`
**Step 1: Write SSE endpoint test**
Modify `src/server.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
// Mock environment variables
vi.stubEnv('SLACK_BOT_TOKEN', 'xoxb-test-token');
vi.stubEnv('PORT', '3001');
describe('Express Server', () => {
let app: express.Application;
let server: ReturnType<typeof app.listen>;
beforeEach(async () => {
// Dynamic import to ensure mocks are applied
const { createApp } = await import('./server.js');
app = createApp();
});
afterEach(() => {
if (server) {
server.close();
}
vi.unstubAllEnvs();
});
it('should respond with platform message on root', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'MCP Server Platform' });
});
it('should have /mcp/slack/sse endpoint', async () => {
const response = await request(app).get('/mcp/slack/sse');
// SSE endpoints should return 200 and keep connection open
// We're just testing the route exists
expect([200, 400, 500]).toContain(response.status);
});
});
```
**Step 2: Install supertest for HTTP testing**
Run:
```bash
npm install --save-dev supertest @types/supertest
```
**Step 3: Run test to verify it fails**
Run:
```bash
npm test src/server.test.ts
```
Expected: FAIL - "createApp is not exported" or "/mcp/slack/sse route not found"
**Step 4: Implement SSE endpoint in server**
Modify `src/server.ts`:
```typescript
import express from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { createSlackMcpServer } from './mcps/slack/index.js';
const DEFAULT_PORT = 3000;
export const createApp = (): express.Application => {
const app = express();
app.get('/', (_req, res) => {
res.json({ message: 'MCP Server Platform' });
});
// Slack MCP SSE endpoint
app.get('/mcp/slack/sse', async (req, res) => {
const token = process.env['SLACK_BOT_TOKEN'];
if (!token) {
res.status(500).json({
error: 'SLACK_BOT_TOKEN environment variable not configured',
});
return;
}
try {
const slackServer = createSlackMcpServer(token);
const transport = new SSEServerTransport('/mcp/slack/sse', res);
await slackServer.connect(transport);
} catch (error) {
console.error('Error setting up Slack MCP server:', error);
res.status(500).json({ error: 'Failed to initialize Slack MCP server' });
}
});
return app;
};
// Start server if run directly
if (import.meta.url === `file://${process.argv[1]}`) {
const app = createApp();
const PORT = process.env['PORT'] ?? DEFAULT_PORT;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Slack MCP available at: http://localhost:${PORT}/mcp/slack/sse`);
});
}
```
**Step 5: Run test to verify it passes**
Run:
```bash
npm test src/server.test.ts
```
Expected: PASS (2 tests)
**Step 6: Commit**
```bash
git add src/server.ts src/server.test.ts package.json package-lock.json
git commit -m "feat: add SSE endpoint for Slack MCP server"
```
---
## Task 11: Environment Variable Validation
**Files:**
- Create: `src/shared/config.ts`
- Create: `src/shared/__tests__/config.test.ts`
**Step 1: Write config validation test**
Create `src/shared/__tests__/config.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { validateConfig } from '../config.js';
describe('validateConfig', () => {
beforeEach(() => {
vi.stubEnv('SLACK_BOT_TOKEN', '');
vi.stubEnv('PORT', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should return valid config when SLACK_BOT_TOKEN is set', () => {
vi.stubEnv('SLACK_BOT_TOKEN', 'xoxb-valid-token');
vi.stubEnv('PORT', '4000');
const config = validateConfig();
expect(config.slackBotToken).toBe('xoxb-valid-token');
expect(config.port).toBe(4000);
});
it('should use default port when PORT not set', () => {
vi.stubEnv('SLACK_BOT_TOKEN', 'xoxb-valid-token');
const config = validateConfig();
expect(config.port).toBe(3000);
});
it('should throw error when SLACK_BOT_TOKEN missing', () => {
expect(() => validateConfig()).toThrow(
'SLACK_BOT_TOKEN environment variable is required'
);
});
it('should throw error when SLACK_BOT_TOKEN empty', () => {
vi.stubEnv('SLACK_BOT_TOKEN', ' ');
expect(() => validateConfig()).toThrow(
'SLACK_BOT_TOKEN environment variable is required'
);
});
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
npm test src/shared/__tests__/config.test.ts
```
Expected: FAIL - "Cannot find module '../config.js'"
**Step 3: Implement config validation**
Create `src/shared/config.ts`:
```typescript
const DEFAULT_PORT = 3000;
export interface AppConfig {
slackBotToken: string;
port: number;
}
export const validateConfig = (): AppConfig => {
const slackBotToken = process.env['SLACK_BOT_TOKEN']?.trim();
if (!slackBotToken) {
throw new Error('SLACK_BOT_TOKEN environment variable is required');
}
const portStr = process.env['PORT'];
const port = portStr ? Number.parseInt(portStr, 10) : DEFAULT_PORT;
return {
slackBotToken,
port,
};
};
```
**Step 4: Run test to verify it passes**
Run:
```bash
npm test src/shared/__tests__/config.test.ts
```
Expected: PASS (4 tests)
**Step 5: Update server.ts to use config validation**
Modify `src/server.ts`:
```typescript
import express from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { createSlackMcpServer } from './mcps/slack/index.js';
import { validateConfig } from './shared/config.js';
export const createApp = (): express.Application => {
const app = express();
app.get('/', (_req, res) => {
res.json({ message: 'MCP Server Platform' });
});
// Slack MCP SSE endpoint
app.get('/mcp/slack/sse', async (req, res) => {
try {
const config = validateConfig();
const slackServer = createSlackMcpServer(config.slackBotToken);
const transport = new SSEServerTransport('/mcp/slack/sse', res);
await slackServer.connect(transport);
} catch (error) {
console.error('Error setting up Slack MCP server:', error);
const message =
error instanceof Error ? error.message : 'Failed to initialize';
res.status(500).json({ error: message });
}
});
return app;
};
// Start server if run directly
if (import.meta.url === `file://${process.argv[1]}`) {
try {
const config = validateConfig();
const app = createApp();
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
console.log(
`Slack MCP available at: http://localhost:${config.port}/mcp/slack/sse`
);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
```
**Step 6: Run all tests**
Run:
```bash
npm test
```
Expected: All tests pass
**Step 7: Commit**
```bash
git add src/shared/config.ts src/shared/__tests__/config.test.ts src/server.ts
git commit -m "feat: add environment variable validation"
```
---
## Task 12: Final Integration Testing
**Files:**
- Modify: `package.json` (add .env.example)
- Create: `.env.example`
**Step 1: Create environment template**
Create `.env.example`:
```bash
# Slack Bot Token (required)
# Get from: https://api.slack.com/apps
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
# Server Port (optional, defaults to 3000)
PORT=3000
```
**Step 2: Run full test suite**
Run:
```bash
npm run ci
```
Expected: All checks pass (typecheck, lint, tests, mutation tests)
**Step 3: Test manual server startup**
Run:
```bash
SLACK_BOT_TOKEN=test npm run dev
```
Expected: Server starts without errors (will fail on actual Slack calls, but startup should work)
Stop with Ctrl+C
**Step 4: Verify build**
Run:
```bash
npm run build
```
Expected: TypeScript compiles successfully to `dist/`
**Step 5: Commit**
```bash
git add .env.example
git commit -m "docs: add environment variable template"
```
---
## Task 13: Documentation
**Files:**
- Create: `docs/slack-mcp-usage.md`
**Step 1: Create usage documentation**
Create `docs/slack-mcp-usage.md`:
```markdown
# Slack MCP Server Usage
## Setup
### 1. Create Slack App
1. Go to https://api.slack.com/apps
2. Click "Create New App" → "From scratch"
3. Name your app and select workspace
4. Navigate to "OAuth & Permissions"
### 2. Add Bot Token Scopes
Add these scopes under "Bot Token Scopes":
- `channels:history` - Read public channel messages
- `channels:read` - List public channels
- `groups:history` - Read private channel messages
- `im:history` - Read DM history
- `users:read` - Get user information
- `search:read` - Use search API
### 3. Install App to Workspace
1. Click "Install to Workspace"
2. Authorize the app
3. Copy the "Bot User OAuth Token" (starts with `xoxb-`)
### 4. Configure Environment
Create `.env` file:
```bash
SLACK_BOT_TOKEN=xoxb-your-actual-token
PORT=3000
```
## Running the Server
### Development
```bash
npm run dev
```
Server available at: `http://localhost:3000/mcp/slack/sse`
### Production
```bash
npm run build
npm start
```
## Using with Claude
Configure Claude Desktop to connect to the Slack MCP server:
```json
{
"mcpServers": {
"slack": {
"url": "http://localhost:3000/mcp/slack/sse"
}
}
}
```
## Available Tools
### search_messages
Search across entire Slack workspace.
**Example:**
```
Search for messages containing "deployment" from last week
```
**Parameters:**
- `query` (required): Search text
- `limit`: Results per page (default: 20, max: 100)
- `cursor`: Pagination token
- `from_date`: Start date (ISO format)
- `to_date`: End date (ISO format)
- `user_id`: Filter by user ID
- `has_reactions`: Only messages with reactions
- `has_threads`: Only messages with threads
### search_in_channel
Search within specific channel.
**Example:**
```
Search for "bug fix" in channel C123456 from user U789
```
**Parameters:**
- `query` (required): Search text
- `channel_id` (required): Channel ID
- All optional parameters from `search_messages`
## Deployment
### Railway
1. Create new project in Railway
2. Connect your Git repository
3. Add environment variables:
- `SLACK_BOT_TOKEN`
4. Deploy
Your MCP endpoint will be: `https://your-app.railway.app/mcp/slack/sse`
## Troubleshooting
### "SLACK_BOT_TOKEN environment variable is required"
Solution: Ensure `.env` file exists with valid token
### "missing_scope" errors
Solution: Add required scopes in Slack app settings and reinstall app
### Rate limiting
Slack API has rate limits. The server passes through rate limit errors to Claude.
```
**Step 2: Commit**
```bash
git add docs/slack-mcp-usage.md
git commit -m "docs: add Slack MCP server usage guide"
```
---
## Task 14: Final Verification
**Step 1: Run complete CI pipeline**
Run:
```bash
npm run ci
```
Expected: All steps pass
- ✅ Type checking
- ✅ Linting
- ✅ Tests
- ✅ Mutation tests (75%+ score)
**Step 2: Verify git status is clean**
Run:
```bash
git status
```
Expected: Clean working tree, all changes committed
**Step 3: Review commit history**
Run:
```bash
git log --oneline
```
Expected: See all feature commits in logical order
---
## Success Criteria
- [x] All dependencies installed
- [x] Shared types defined and tested
- [x] SlackClient service implemented with full test coverage
- [x] search_messages tool with Zod validation
- [x] search_in_channel tool with Zod validation
- [x] Slack MCP server registered tools
- [x] Express SSE endpoint configured
- [x] Environment validation implemented
- [x] All tests passing (unit + integration)
- [x] Documentation complete
- [x] TypeScript compiles without errors
- [x] Biome linting passes
- [x] Mutation test score ≥75%
## Next Steps
After implementation:
1. Test with real Slack workspace
2. Deploy to Railway
3. Configure Claude Desktop
4. Add more MCP integrations (GitHub, Jira, etc.)