Guide · MCP Slack Integration
MCP Server Slack — Block Kit messages, cursor pagination, and health probes via auth.test
Slack is the most common internal communication surface for agent-driven notifications. This guide covers building TypeScript MCP tools for the Slack Web API: posting messages with Block Kit composition and confirm guards, listing users and conversations with cursor-based pagination, uploading files, and wiring a /health/slack endpoint that calls auth.test to verify token validity and scope before AliveMCP alerts you that your Slack integration has silently broken.
TL;DR
Use a Bot Token (xoxb-*) via Authorization: Bearer header, not a Legacy token. The Slack Web API always returns HTTP 200 — distinguish success from failure by checking the ok boolean field, never the HTTP status code. Messages posted by chat.postMessage cannot be unsent once sent — always add a confirm: z.literal(true) guard. Slack enforces per-method tier-based rate limits (Tier 4 is 1+ req/s, Tier 1 is 1+ req/min for expensive list operations). Paginate all list operations via the cursor field in response_metadata.next_cursor, not an offset. Wire auth.test as your health probe — it validates token, returns bot_id, team_id, and the token's scopes, catching revoked tokens that still appear valid from the outside.
SDK setup and authentication
Slack's official @slack/web-api package wraps the Slack Web API with TypeScript types and automatic rate limit retrying. Install it alongside Zod for MCP input validation.
import { WebClient, LogLevel } from '@slack/web-api';
import { z } from 'zod';
// Bot Token — starts with xoxb-
// Never use User Tokens (xoxp-) in server-side integrations
const slack = new WebClient(process.env.SLACK_BOT_TOKEN, {
logLevel: LogLevel.ERROR,
retryConfig: {
retries: 3,
factor: 1.5,
minTimeout: 1000,
maxTimeout: 30_000
}
});
// The SDK handles Retry-After headers automatically when retryConfig is set.
// For manual axios usage, Slack rate limit responses are HTTP 429 with
// Retry-After header (seconds). Check the 'error' field for 'ratelimited'.
Slack tokens come in several types. Use the right one for each scenario:
| Token type | Prefix | Use case | Recommended |
|---|---|---|---|
| Bot Token | xoxb- |
App/Bot operations (post, read channels the bot is in) | Yes — use this |
| User Token | xoxp- |
Operations on behalf of a specific user | Only when required |
| App-Level Token | xapp- |
Socket Mode WebSocket connections | For real-time events only |
| Legacy Token | xoxo-, xoxe- |
Old integration style, full workspace access | Never — being deprecated |
Posting messages with Block Kit and confirm guards
The most critical Slack MCP tool is chat.postMessage. Messages sent to Slack cannot be recalled — once delivered, they reach every member of the channel. Always add a confirm guard to any send operation. Block Kit allows rich message composition with interactive elements, but for MCP tools focus on static Block Kit blocks: section, header, divider, and context.
server.tool('send_slack_message', {
channel: z.string().describe('Channel ID (C...) or name (#general). Bot must be a member.'),
text: z.string().describe('Plain text fallback for notifications and accessibility.'),
blocks: z.array(z.object({
type: z.enum(['section', 'header', 'divider', 'context']),
text: z.object({
type: z.enum(['mrkdwn', 'plain_text']),
text: z.string()
}).optional(),
elements: z.array(z.any()).optional()
})).optional().describe('Block Kit blocks for rich formatting. Falls back to text if omitted.'),
thread_ts: z.string().optional().describe('Timestamp of parent message to reply in-thread.'),
confirm: z.literal(true).describe('Messages cannot be unsent. Pass true to confirm.')
}, async ({ channel, text, blocks, thread_ts }) => {
const res = await slack.chat.postMessage({
channel,
text,
blocks,
thread_ts,
// unfurl_links: false reduces noise for URL-heavy messages
unfurl_links: false
});
// Slack ALWAYS returns HTTP 200. Check res.ok, never HTTP status.
if (!res.ok) {
throw new Error(`Slack API error: ${res.error}`);
}
return {
content: [{
type: 'text',
text: `Message sent. ts=${res.ts} channel=${res.channel}`
}]
};
});
Block Kit section block example for structured agent notifications:
const blocks = [
{
type: 'header',
text: { type: 'plain_text', text: ':rotating_light: Deployment Alert' }
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Service:* \`${serviceName}\`\n*Status:* ${status}\n*Environment:* ${env}`
}
},
{
type: 'context',
elements: [
{ type: 'mrkdwn', text: `Triggered at ` }
]
}
];
Listing users with cursor pagination
Slack's users.list and other list methods use cursor-based pagination — not offset/limit. The SDK's paginator utility handles this automatically, but for MCP tools you typically want to expose pagination controls directly rather than fetching all pages server-side (which can take minutes for large workspaces).
server.tool('list_slack_users', {
cursor: z.string().optional().describe('Pagination cursor from previous response next_cursor.'),
limit: z.number().int().min(1).max(200).default(100).describe('Users per page. Max 200.')
}, async ({ cursor, limit }) => {
const res = await slack.users.list({ cursor, limit });
if (!res.ok) {
throw new Error(`users.list failed: ${res.error}`);
}
const users = (res.members ?? [])
.filter(u => !u.deleted && !u.is_bot)
.map(u => ({
id: u.id,
name: u.name,
real_name: u.real_name,
email: u.profile?.email,
is_admin: u.is_admin,
tz: u.tz
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
users,
next_cursor: res.response_metadata?.next_cursor ?? null
}, null, 2)
}]
};
});
| Slack API method | Rate limit tier | Requests per minute | Notes |
|---|---|---|---|
users.list |
Tier 2 | 20+/min | Expensive — cache results |
conversations.list |
Tier 2 | 20+/min | Exclude archived with exclude_archived: true |
chat.postMessage |
Tier 4 | 1+/s per channel | Actually 1 per second, not per minute |
reactions.add |
Tier 3 | 50+/min | Per workspace, not per channel |
files.upload |
Tier 3 | 50+/min | Use v2 method: files.getUploadURLExternal |
auth.test |
Tier 1 | 1+/min | Don't poll — use for health probes only |
Listing conversations with type filters
Workspaces can contain public channels, private channels, DMs, and group DMs. Filter with the types parameter to avoid pulling irrelevant conversations. The bot only sees channels it has been invited to — for private channels, invitation must happen before conversations.list returns them.
server.tool('list_slack_channels', {
types: z.array(z.enum(['public_channel', 'private_channel', 'mpim', 'im']))
.default(['public_channel'])
.describe('Channel types to include.'),
exclude_archived: z.boolean().default(true),
cursor: z.string().optional(),
limit: z.number().int().min(1).max(1000).default(200)
}, async ({ types, exclude_archived, cursor, limit }) => {
const res = await slack.conversations.list({
types: types.join(','),
exclude_archived,
cursor,
limit
});
if (!res.ok) throw new Error(`conversations.list failed: ${res.error}`);
const channels = (res.channels ?? []).map(c => ({
id: c.id,
name: c.name,
is_private: c.is_private,
is_member: c.is_member,
num_members: c.num_members,
topic: c.topic?.value
}));
return {
content: [{
type: 'text',
text: JSON.stringify({ channels, next_cursor: res.response_metadata?.next_cursor ?? null }, null, 2)
}]
};
});
File uploads via the v2 method
Slack deprecated the original files.upload method in 2024. The v2 upload flow requires three steps: get an upload URL, upload the file bytes, and complete the upload with channel sharing. The @slack/web-api SDK's filesUploadV2 wraps all three steps.
server.tool('upload_slack_file', {
channel: z.string().describe('Channel ID to share the file into.'),
filename: z.string().describe('Filename with extension shown in Slack.'),
content: z.string().describe('File content as UTF-8 text.'),
title: z.string().optional(),
initial_comment: z.string().optional().describe('Message posted alongside the file.')
}, async ({ channel, filename, content, title, initial_comment }) => {
// SDK v7+ exposes filesUploadV2 which handles the 3-step flow
const res = await slack.filesUploadV2({
channel_id: channel,
filename,
content,
title,
initial_comment
});
if (!res.ok) throw new Error(`File upload failed: ${res.error}`);
return {
content: [{
type: 'text',
text: `File uploaded. id=${res.files?.[0]?.id}`
}]
};
});
Wiring /health/slack via auth.test
auth.test is the canonical Slack health probe. It requires no special permissions, validates token authenticity, returns the team and bot identifiers, and — critically — lists the token's granted OAuth scopes. A passing auth.test confirms your bot token is valid, not revoked, and associated with the workspace you expect.
// Express/Fastify health endpoint
app.get('/health/slack', async (req, res) => {
try {
const result = await slack.auth.test();
if (!result.ok) {
return res.status(503).json({
status: 'unhealthy',
error: result.error
});
}
return res.json({
status: 'healthy',
bot_id: result.bot_id,
team: result.team,
team_id: result.team_id,
user: result.user, // The bot's display name
scopes: result.response_metadata?.scopes
});
} catch (err: any) {
return res.status(503).json({
status: 'unhealthy',
error: err.message,
// Slack SDK wraps errors; check err.data?.error for the Slack error code
slack_error: err.data?.error
});
}
});
| auth.test error code | Meaning | Remediation |
|---|---|---|
invalid_auth |
Token is invalid or has been revoked | Reinstall the Slack app or regenerate the Bot Token |
not_authed |
No token provided in the request | Check the SLACK_BOT_TOKEN env var is set |
token_revoked |
Token was explicitly revoked (e.g. app uninstalled) | Reinstall the app and generate a new Bot Token |
token_expired |
Token has expired (only affects short-lived tokens) | Refresh via the OAuth flow or use a non-expiring Bot Token |
account_inactive |
Workspace has been deactivated | No remediation — workspace is gone |
missing_scope |
Token lacks a required OAuth scope | Add scope in app configuration and reinstall |
Wire AliveMCP to poll /health/slack every 60 seconds. Token revocations are not gradual — a valid token becomes invalid instantly when an app is uninstalled or a token is rotated. AliveMCP's polling catches this before your first failed agent run.
Frequently asked questions
Why does Slack always return HTTP 200 even when an error occurs?
The Slack Web API uses HTTP 200 for all API responses, including errors. The ok boolean field in the JSON body indicates success (true) or failure (false). When ok is false, the error field contains a string error code like channel_not_found or not_in_channel. Never check the HTTP status code to determine if a Slack API call succeeded — always check res.ok. The only exception is rate limiting, which returns HTTP 429 with a Retry-After header.
What's the difference between RTM and Socket Mode?
Real Time Messaging (RTM) is deprecated and no longer available to new Slack apps. Socket Mode is the modern replacement for receiving real-time events over a WebSocket connection. The key distinction for MCP tools is that Socket Mode is only needed for receiving events (messages posted to channels, reactions, etc.). For MCP tools that send messages and query data, the REST Web API over HTTPS is correct — no Socket Mode needed. Only add Socket Mode if your MCP server needs to react to events rather than just take actions.
How do I handle the bot not being in a channel?
When chat.postMessage fails with not_in_channel, the bot needs to be invited first. You have two options: (1) join the channel programmatically via conversations.join (only works for public channels), or (2) have a workspace admin invite the bot. For private channels, only the admin invite path works. For MCP tools, it's better to surface not_in_channel errors clearly than to silently auto-join, which could violate your workspace's channel organization. Present the error to the agent and let it decide whether to use a different channel or request admin intervention.
Can I read messages from a channel with an MCP tool?
Yes, via conversations.history (requires the channels:history or groups:history scope depending on channel type). Responses are paginated via cursor like all list methods. The ts field on each message is its unique identifier and timestamp combined — use oldest and latest parameters to filter by time range. For threading, use conversations.replies with the parent thread_ts. Note that deleted messages are not returned; reactions and file uploads appear as message subtypes.
Further reading
- MCP Server Discord — Bot token auth, send messages with embeds, and guild member management
- MCP Server PagerDuty — create incidents, manage on-call schedules, and health probes via /users/me
- MCP Server Webhooks — sending structured payloads with retry logic
- MCP Server Health Check — wiring /health endpoints for uptime monitoring
- MCP Server Error Handling — structured errors and retry patterns