Communication Platforms · 2026-07-09 · Communication Platforms arc
MCP Tools for Communication Platforms: The Confirm Guard, Rate Limit Diversity, and Health Probe Scope Validation Across Slack, Discord, Twilio, SendGrid, and PagerDuty
Communication platform integrations share one property that almost no other category does: every destructive operation is irreversible. A message sent to 80,000 Slack users, an SMS delivered to a carrier, an incident page that woke someone at 3 AM — none of these can be unsent at the API level. We integrated five major communication platforms as MCP tools — Slack, Discord, Twilio, SendGrid, and PagerDuty — and three patterns emerged that every one of them requires: the confirm guard before destructive sends, a rate limit model that is unique to each platform, and a health probe that validates scope rather than just connectivity. This is the synthesis.
Three patterns, five platforms
The five platforms covered here span the full communication stack: Slack is the workplace messaging layer — channel-based, persistent, and visible to every member. Discord is community and gaming chat — server-and-channel model, aggressive per-route rate limiting, and a Bot token format that is distinct from the OAuth Bearer format. Twilio is carrier-layer SMS and voice — once your HTTP 201 comes back, the message is already in a carrier queue. SendGrid is transactional email — the HTTP 202 Accepted response means queued, not delivered, and once the batch processes there is no recall. PagerDuty is on-call incident management — create_incident fires phone calls and SMS to on-call engineers immediately, regardless of the hour. Despite covering completely different delivery surfaces, all five share the same three failure patterns:
- The confirm guard pattern — every platform has at least one "send" operation whose effect cannot be undone. An MCP tool that calls these without an explicit confirmation step will fire the action on the first LLM instruction, before the operator has reviewed the payload.
- The rate limit diversity pattern — every platform uses a structurally different rate limit model: Slack uses per-method tiers, Discord uses per-route bucket headers, Twilio uses soft recommendations with carrier-level throttling, SendGrid uses a hard per-minute cap with a Unix timestamp reset, and PagerDuty uses a per-minute cap with a seconds-offset reset field. A single retry interceptor cannot handle all five correctly.
- The health probe scope validation pattern — every platform has a specific failure mode that a connectivity-only health check will miss. The correct probe for each platform validates not just "can I reach the API" but "does my credential have the permissions this tool needs".
Pattern 1: The confirm guard
The confirm guard is an error prevention pattern that inserts an explicit approval step before any tool call whose side effect cannot be reversed. In communication platforms, the side effect is message delivery — and delivery is always immediate from the platform's perspective even when it feels asynchronous from the API's perspective.
The table below maps the "send" operation for each platform against the exact moment delivery becomes irreversible:
| Platform | Send operation | Irreversibility point | HTTP response code | Can API undo? |
|---|---|---|---|---|
| Slack | chat.postMessage |
On 200 OK — all channel members notified immediately | 200 OK with ok: true |
No — chat.delete removes the message body but push notifications are already sent |
| Discord | POST /channels/:id/messages |
On 200 OK — Discord gateway push is immediate | 200 OK with message object | No — DELETE /channels/:id/messages/:mid removes from UI but notification history is not recalled |
| Twilio | messages.create |
On 201 Created — message is in carrier queue | 201 Created with status: queued |
No — carrier delivery cannot be cancelled after 201 |
| SendGrid | mail.send |
Once batch processes — HTTP 202 means queued | 202 Accepted (empty body) | No — batch ID cancellation is not exposed via the v3 API |
| PagerDuty | POST /incidents |
On 201 Created — on-call engineer is called immediately | 201 Created with incident object | No — acknowledging or resolving the incident does not recall the phone call |
Slack: the postMessage confirm guard
Slack's chat.postMessage delivers to channel members immediately. Notifications are pushed to every member's mobile client before the HTTP response returns to your code. A Slack MCP tool that calls this without a confirm guard will send the message on the first LLM intent to send — no preview, no scope check. The correct implementation wraps the send in a two-step tool sequence: a preview_message tool that returns the formatted payload for review, and a confirm_send_message tool that calls the Slack API only after the operator approves.
server.tool('preview_slack_message', {
channel: z.string().describe('Channel ID or name, e.g. #alerts'),
text: z.string().describe('Message text — markdown supported'),
blocks: z.array(z.any()).optional()
}, async ({ channel, text, blocks }) => {
// Look up channel name without sending anything
const channelInfo = await slackClient.conversations.info({ channel });
const memberCount = channelInfo.channel?.num_members ?? 0;
return {
content: [{
type: 'text',
text: JSON.stringify({
action: 'PENDING_CONFIRMATION',
warning: `This message will be sent to ${memberCount} members of ${channelInfo.channel?.name}. Use confirm_send_message to proceed.`,
preview: { channel, text, blocks: blocks ?? [] }
}, null, 2)
}]
};
});
server.tool('confirm_send_message', {
channel: z.string(),
text: z.string(),
blocks: z.array(z.any()).optional()
}, async ({ channel, text, blocks }) => {
const result = await slackClient.chat.postMessage({
channel,
text,
blocks,
unfurl_links: false
});
return {
content: [{
type: 'text',
text: JSON.stringify({
ok: result.ok,
ts: result.ts,
channel: result.channel
})
}]
};
});
The unfurl_links: false is not cosmetic — if your message contains a URL, Slack will immediately fetch that URL to generate a preview. That fetch happens before the chat.postMessage response returns, and it originates from Slack's servers with your workspace's identity. Disabling unfurls prevents unintended outbound requests from the platform.
Discord: delivery semantics and the delete gap
Discord's message delivery model is subtly different from Slack's. The HTTP response from POST /channels/:id/messages returns before the Gateway event is delivered to every connected client — but Gateway delivery still happens within milliseconds. The gap between HTTP 200 and push notification delivery is approximately 50–200ms in normal operating conditions. This is too short to be operationally useful as a cancel window.
More importantly: Discord's DELETE /channels/:id/messages/:message_id removes the message from the channel view, but it does not recall mobile push notifications. Any member who received a mobile push for the message retains the notification text even if the message is deleted. The confirm guard for Discord therefore needs to be identical in strictness to Slack's — deletion is a UI operation, not a delivery reversal.
server.tool('preview_discord_message', {
channelId: z.string().describe('Discord channel ID (numeric snowflake)'),
content: z.string().describe('Message content — 2000 character limit'),
embeds: z.array(z.any()).optional()
}, async ({ channelId, content, embeds }) => {
if (content.length > 2000) {
throw new Error(`Content exceeds 2000 character limit (${content.length} chars)`);
}
// Fetch channel metadata without sending
const channel = await discordClient.channels.fetch(channelId);
return {
content: [{
type: 'text',
text: JSON.stringify({
action: 'PENDING_CONFIRMATION',
warning: 'This message will be delivered immediately via Discord Gateway. Mobile push notifications cannot be recalled. Use confirm_discord_message to proceed.',
preview: {
channelName: channel?.name,
guildName: channel?.guild?.name,
contentPreview: content.slice(0, 200) + (content.length > 200 ? '…' : ''),
embedCount: (embeds ?? []).length
}
}, null, 2)
}]
};
});
Twilio: the 201 Created point of no return
Twilio's SMS delivery model uses HTTP 201 Created (not 200 OK) for messages.create. The 201 status code means the message has been accepted and placed in a carrier queue — at that point, Twilio has handed the message to a carrier gateway. The status: 'queued' field in the response body is Twilio's internal queue status, not an indication that delivery can be cancelled.
Twilio does have a POST /Messages/:Sid/cancel endpoint for messages with status queued or scheduled, but this only works for messages that have not yet left Twilio's queue. In practice, simple messages.create calls process so quickly that the cancel window is effectively zero. The confirm guard is therefore mandatory before the initial create call, not after.
server.tool('preview_sms', {
to: z.string().describe('Recipient phone number in E.164 format (+12125551234)'),
from: z.string().describe('Twilio phone number or messaging service SID'),
body: z.string().max(1600).describe('SMS body — up to 1600 chars (multi-segment for >160)')
}, async ({ to, from, body }) => {
const segments = Math.ceil(body.length / 160);
const isShortCode = from.startsWith('SC');
// Estimate cost: $0.0079/segment for standard US numbers
const estimatedCost = segments * 0.0079;
return {
content: [{
type: 'text',
text: JSON.stringify({
action: 'PENDING_CONFIRMATION',
warning: `This SMS will be delivered to carrier immediately on confirmation. HTTP 201 = point of no return.`,
preview: {
to,
from,
bodyPreview: body.slice(0, 160) + (body.length > 160 ? ` [+${body.length - 160} chars, ${segments} segments]` : ''),
estimatedCost: `$${estimatedCost.toFixed(4)}`,
fromType: isShortCode ? 'short-code (carrier class A rate limits)' : 'long-code'
}
}, null, 2)
}]
};
});
SendGrid: the HTTP 202 Accepted trap
SendGrid's POST /v3/mail/send returns HTTP 202 Accepted with an empty response body. There is no batch ID, no tracking token, and no cancel endpoint in the response. The 202 means "your message was accepted into our send queue" — once the queued batch processes (typically within seconds for transactional email), delivery is irreversible.
A second confirm guard layer applies specifically to SendGrid's suppression management: if a recipient was previously on a bounce list and you call DELETE /v3/suppression/bounces/:email followed by a new send, that is two irreversible operations chained together. The SendGrid MCP server should require separate confirmations for suppression removal and subsequent send — not treat them as a single atomic "clean and resend" operation.
server.tool('preview_email', {
to: z.array(z.object({ email: z.string(), name: z.string().optional() })),
from: z.object({ email: z.string(), name: z.string().optional() }),
subject: z.string(),
htmlContent: z.string().optional(),
textContent: z.string().optional(),
templateId: z.string().optional()
}, async ({ to, from, subject, htmlContent, textContent, templateId }) => {
if (!htmlContent && !textContent && !templateId) {
throw new Error('Email must have htmlContent, textContent, or templateId');
}
return {
content: [{
type: 'text',
text: JSON.stringify({
action: 'PENDING_CONFIRMATION',
warning: `This email will be queued immediately. HTTP 202 Accepted = no cancel mechanism. Use confirm_send_email to proceed.`,
preview: {
recipientCount: to.length,
recipients: to.slice(0, 3).map(r => r.email).join(', ') + (to.length > 3 ? ` +${to.length - 3} more` : ''),
from: from.email,
subject,
contentType: templateId ? `template:${templateId}` : (htmlContent ? 'html' : 'text'),
estimatedBodyBytes: (htmlContent ?? textContent ?? '').length
}
}, null, 2)
}]
};
});
PagerDuty: the 3 AM phone call
PagerDuty's incident creation is the most severe confirm guard case in this arc. POST /incidents triggers an escalation policy immediately — the on-call engineer receives a phone call, followed by SMS, followed by push notification, cycling until they acknowledge. There is no "quiet" mode at the API level. Creating an incident always fires the escalation policy as configured, regardless of business hours or urgency settings unless those are explicitly set in the request body.
The confirm guard for PagerDuty must show three critical pieces before proceeding: the on-call engineer's name (from GET /oncalls), the escalation policy name, and the urgency level. An agent that creates a test incident against a production service at 3 AM because a human said "can you create an incident for the database issue?" without disambiguation has caused real harm.
server.tool('preview_incident', {
title: z.string().describe('Incident title — what the on-call engineer will read'),
serviceId: z.string().describe('PagerDuty service ID (Pxxxxxxx)'),
urgency: z.enum(['high', 'low']).describe('high = immediate phone call; low = push notification only'),
body: z.string().optional().describe('Incident details')
}, async ({ title, serviceId, urgency, body }) => {
// Fetch current on-call and service info
const [oncalls, service] = await Promise.all([
pdClient.get(`/oncalls?include[]=users&escalation_policy_ids[]=${serviceId}`),
pdClient.get(`/services/${serviceId}`)
]);
const currentOncall = oncalls.data.oncalls?.[0]?.user?.summary ?? 'unknown';
const serviceName = service.data.service?.name ?? serviceId;
const escalationPolicy = service.data.service?.escalation_policy?.summary ?? 'unknown';
return {
content: [{
type: 'text',
text: JSON.stringify({
action: 'PENDING_CONFIRMATION',
warning: urgency === 'high'
? `URGENT: This will immediately phone ${currentOncall}. Use confirm_create_incident to proceed.`
: `This will send a push notification to ${currentOncall}. Use confirm_create_incident to proceed.`,
preview: {
title,
service: serviceName,
escalationPolicy,
urgency,
onCall: currentOncall,
bodyPreview: body?.slice(0, 200)
}
}, null, 2)
}]
};
});
Pattern 2: Rate limit diversity
Every communication platform uses a structurally different rate limit model. The failure mode from treating them identically is not just "you get 429 errors" — it's that a retry interceptor calibrated for one platform will produce the wrong backoff interval, waste quota on retry storms, or silently skip retries that should have been attempted. See the MCP server rate limiting guide for the underlying retry infrastructure; this section covers what is specific to each communication platform.
| Platform | Rate limit model | 429 retry header | Reset field type | Global vs per-endpoint |
|---|---|---|---|---|
| Slack | Tier-based per-method (Tier 1/2/3/4) | Retry-After (seconds) |
Seconds until reset | Per-method; no global bot limit |
| Discord | Per-route bucket via X-RateLimit-Bucket |
X-RateLimit-Reset-After (float seconds) |
Float seconds; also X-RateLimit-Reset Unix epoch |
Per-route bucket + global 50 req/s bot limit |
| Twilio | Soft 100 req/s recommendation | Retry-After (if returned) |
Seconds (rare; usually 429 is from Twilio sub-services) | Per-account; sub-resources may differ |
| SendGrid | Hard 600 req/min (10 req/s) | X-RateLimit-Reset |
Unix timestamp (seconds) | Per-API-key across all endpoints |
| PagerDuty | Hard 900 req/min (15 req/s) | X-RateLimit-Reset |
Seconds until reset (NOT Unix timestamp) | Per-account; webhook endpoints separate |
Slack: tier-based per-method limits
Slack uses a tier system where each API method is assigned a tier (Tier 1 through Tier 4, plus a special tier for some methods), and the rate limit applies per-method per-workspace. The critical asymmetry in the Slack MCP server is that the methods you use for health probes and method discovery (auth.test, conversations.list, users.list) are on Tier 1 — 1+ request per minute — while the send method (chat.postMessage) is on Tier 4, which allows much higher throughput (burst up to 1+ request per second).
This means an MCP server that polls conversations.list to keep a channel cache fresh will exhaust Tier 1 quota long before chat.postMessage is affected. The Slack SDK for Node.js handles the Retry-After header automatically when you set the retryConfig option, but it treats all methods identically for retry purposes — you must implement per-tier rate limiting at the tool layer if you need to protect high-value send operations from being blocked by poll quota exhaustion.
import { WebClient, retryPolicies } from '@slack/web-api';
// Separate client instances per tier to avoid cross-tier quota contamination
const tier4Client = new WebClient(SLACK_BOT_TOKEN, {
retryConfig: retryPolicies.fiveRetriesInFiveMinutes
});
const tier1Client = new WebClient(SLACK_BOT_TOKEN, {
retryConfig: {
retries: 2,
factor: 2,
minTimeout: 60_000, // Tier 1: 1+/min → wait 60s on retry
maxTimeout: 120_000,
randomize: true
}
});
// Use tier4Client for chat.postMessage; tier1Client for list/info methods
async function postMessage(channel: string, text: string) {
return tier4Client.chat.postMessage({ channel, text });
}
async function listChannels(cursor?: string) {
return tier1Client.conversations.list({ limit: 200, cursor });
}
Discord: per-route bucket headers
Discord's rate limit system is the most complex of the five platforms. Each API route is assigned to a bucket, identified by the X-RateLimit-Bucket response header. Two routes can share the same bucket (in which case they share quota), and the same route can be in different buckets for different resources (e.g., posting to channel A and channel B may be in different buckets).
The header set on every Discord response includes: X-RateLimit-Limit (bucket size), X-RateLimit-Remaining (requests left in current window), X-RateLimit-Reset (Unix epoch of reset, seconds), X-RateLimit-Reset-After (float seconds until reset), and X-RateLimit-Bucket (bucket ID). The correct retry implementation reads X-RateLimit-Reset-After — not a fixed backoff — because the window size varies per bucket and per resource.
There is also a global limit of 50 requests per second per bot token, signalled by a 429 with {"global": true} in the body. This is handled differently from per-route limits: the global 429 body contains a retry_after field in seconds (float), not in the headers.
interface BucketState {
remaining: number;
resetAfterMs: number;
resetAt: number;
}
const bucketCache = new Map<string, BucketState>();
async function discordRequest<T>(
method: string,
path: string,
body?: unknown
): Promise<T> {
const bucketId = bucketCache.get(path)?.toString();
// Check bucket remaining before sending
if (bucketId) {
const state = bucketCache.get(bucketId);
if (state && state.remaining === 0 && state.resetAt > Date.now()) {
await sleep(state.resetAt - Date.now() + 50); // 50ms jitter
}
}
const res = await fetch(`https://discord.com/api/v10${path}`, {
method,
headers: {
'Authorization': `Bot ${DISCORD_BOT_TOKEN}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
// Update bucket state from response headers
const bucket = res.headers.get('X-RateLimit-Bucket');
if (bucket) {
bucketCache.set(path, bucket as unknown as BucketState);
bucketCache.set(bucket, {
remaining: parseInt(res.headers.get('X-RateLimit-Remaining') ?? '1'),
resetAfterMs: parseFloat(res.headers.get('X-RateLimit-Reset-After') ?? '1') * 1000,
resetAt: parseFloat(res.headers.get('X-RateLimit-Reset') ?? '0') * 1000
});
}
if (res.status === 429) {
const data = await res.json() as { global?: boolean; retry_after: number };
await sleep(data.retry_after * 1000 + 50);
return discordRequest(method, path, body); // single retry
}
return res.json() as Promise<T>;
}
Twilio: geographic restrictions and carrier throttling
Twilio's API-level rate limit is a soft 100 requests per second recommendation — not a hard limit enforced by 429 responses in most normal usage. The real rate limiting in Twilio SMS comes from carrier-level throttling, which varies by destination country, carrier, and number type (long code vs short code vs toll-free). Error code 21408 (Permission to send an SMS has not been enabled for the region indicated by the 'To' number) is a geographic restriction, not a rate limit, but manifests in the same way — 400 response, retry won't help.
The two Twilio throttling signals worth handling in your MCP tool are: error code 30008 (Unknown error from carrier) which often accompanies carrier-level throttling and should trigger exponential backoff, and error code 21610 (The message From/To pair violates a blocklist rule) which should be surfaced immediately without retry.
async function sendSmsWithRetry(
to: string,
from: string,
body: string,
maxAttempts = 3
): Promise<string> {
const CARRIER_THROTTLE_CODES = new Set(['30008', '30007', '30005']);
const NO_RETRY_CODES = new Set(['21610', '21408', '21211']);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const msg = await twilioClient.messages.create({ to, from, body });
return msg.sid;
} catch (err: any) {
const code = String(err.code);
if (NO_RETRY_CODES.has(code)) {
throw new Error(`Non-retryable Twilio error ${code}: ${err.message}`);
}
if (CARRIER_THROTTLE_CODES.has(code) && attempt < maxAttempts - 1) {
const backoffMs = Math.pow(2, attempt) * 5000; // 5s, 10s, 20s
await sleep(backoffMs);
continue;
}
throw err;
}
}
throw new Error('Max retry attempts exceeded');
}
SendGrid: hard 600 req/min with Unix timestamp reset
SendGrid enforces a hard limit of 600 requests per minute per API key. All API endpoints share this quota — a single API key used for both sending email and querying statistics will hit the same 600 req/min cap. The 429 response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. The X-RateLimit-Reset value is a Unix timestamp in seconds (not a duration), so you must compute the wait time as reset * 1000 - Date.now().
For bulk sending scenarios, SendGrid's Batch Email feature (using batch_id) is a different code path that bypasses per-key rate limits by scheduling sends. The SendGrid MCP server should prefer POST /v3/mail/batch for any send targeting more than ~200 recipients, since the batch endpoint's rate limit is per-batch rather than per-request.
async function sendgridRequest<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(`https://api.sendgrid.com/v3${path}`, {
...options,
headers: {
'Authorization': `Bearer ${SENDGRID_API_KEY}`,
'Content-Type': 'application/json',
...options.headers
}
});
if (res.status === 429) {
const resetHeader = res.headers.get('X-RateLimit-Reset');
if (resetHeader) {
// Reset is Unix timestamp in seconds
const resetMs = parseInt(resetHeader) * 1000;
const waitMs = Math.max(resetMs - Date.now(), 0) + 100; // 100ms buffer
await sleep(waitMs);
return sendgridRequest(path, options); // single retry after reset
}
throw new Error('SendGrid 429: no X-RateLimit-Reset header');
}
if (!res.ok) {
const errorBody = await res.json().catch(() => ({ errors: [] }));
const errors = errorBody.errors?.map((e: any) => e.message).join('; ') ?? res.statusText;
throw new Error(`SendGrid ${res.status}: ${errors}`);
}
// SendGrid mail.send returns 202 with no body
if (res.status === 202) return {} as T;
return res.json() as Promise<T>;
}
PagerDuty: 900 req/min with seconds-offset reset
PagerDuty allows 900 requests per minute (15 per second). The rate limit headers are X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset — but PagerDuty's X-RateLimit-Reset value is seconds until reset, not a Unix timestamp. This is the opposite of SendGrid's convention and the same field name. A generic retry interceptor that treats X-RateLimit-Reset as a Unix timestamp will compute a wildly incorrect wait time for PagerDuty — either waiting 1,750,000,000+ milliseconds (the value interpreted as ms) or 0 ms if the reset value is treated as already-elapsed.
async function pagerdutyRequest<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(`https://api.pagerduty.com${path}`, {
...options,
headers: {
'Authorization': `Token token=${PAGERDUTY_API_KEY}`, // note double-token format
'Accept': 'application/vnd.pagerduty+json;version=2',
'Content-Type': 'application/json',
...options.headers
}
});
if (res.status === 429) {
const resetHeader = res.headers.get('X-RateLimit-Reset');
if (resetHeader) {
// PagerDuty: reset is SECONDS FROM NOW (not Unix timestamp)
const waitMs = parseInt(resetHeader) * 1000 + 100;
await sleep(waitMs);
return pagerdutyRequest(path, options);
}
await sleep(5000); // fallback: 5s
return pagerdutyRequest(path, options);
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`PagerDuty ${res.status}: ${body.error?.message ?? res.statusText}`);
}
return res.json() as Promise<T>;
}
Pattern 3: Health probe scope validation
A health probe that only checks connectivity — "can I reach the API endpoint" — will return healthy when every tool call is failing. The correct health check for a communication platform MCP server must validate three things simultaneously: (1) network reachability, (2) credential validity, and (3) scope coverage for the operations this MCP tool actually performs. All five platforms have a specific endpoint that validates all three with a single request.
| Platform | Correct health probe endpoint | What it validates beyond connectivity | Common wrong probe | What the wrong probe misses |
|---|---|---|---|---|
| Slack | auth.test |
Token validity AND returns granted scopes array | api.test |
api.test is unauthenticated — passes even with invalid token |
| Discord | GET /users/@me |
Validates Bot token format; returns application info | GET /gateway |
GET /gateway is unauthenticated — returns URL without validating credentials |
| Twilio | GET /2010-04-01/Accounts/:AccountSid |
Validates credentials AND returns status field (active/suspended/closed) |
GET /2010-04-01/Accounts |
List endpoint doesn't return individual account status — misses suspended accounts |
| SendGrid | GET /v3/scopes |
Validates key AND returns granted permission list — catches scope erosion | GET /v3/user/profile |
Profile endpoint doesn't return permissions — misses key with revoked scopes |
| PagerDuty | GET /users/me |
Validates Token header format AND returns user role | GET /abilities |
Abilities endpoint accepts any valid account token — doesn't validate specific user permissions |
Slack: auth.test validates token and returns scopes
Slack's auth.test method is the definitive credential validator. It returns the bot user's identity, workspace, and a scopes field in the response object (not in a header) listing every scope the token has been granted. The common wrong choice is api.test, which returns {"ok": true} regardless of whether you pass a valid token — it is a connectivity-only check that accepts any request, authenticated or not.
async function slackHealthCheck(): Promise<{
healthy: boolean;
teamId: string;
botUserId: string;
scopes: string[];
missingScopes: string[];
}> {
const REQUIRED_SCOPES = ['chat:write', 'channels:read', 'channels:history'];
const result = await slackClient.auth.test();
if (!result.ok) {
return { healthy: false, teamId: '', botUserId: '', scopes: [], missingScopes: REQUIRED_SCOPES };
}
// Scopes are returned as a space-separated string in the response
// For bot tokens, check the X-OAuth-Scopes header instead
const grantedScopes = (result as any).response_metadata?.scopes ?? [];
const missingScopes = REQUIRED_SCOPES.filter(s => !grantedScopes.includes(s));
return {
healthy: result.ok && missingScopes.length === 0,
teamId: result.team_id as string,
botUserId: result.bot_id as string,
scopes: grantedScopes,
missingScopes
};
}
Note that auth.test is on Tier 1 (1+ request per minute) — poll it at most once per minute in your health monitoring loop to avoid consuming the quota that your list methods need.
Discord: GET /users/@me validates Bot token format
The Discord Bot token format is Authorization: Bot MTc... — the word "Bot" followed by a space, then the token. This is structurally different from an OAuth Bearer token (Authorization: Bearer ...). Sending a Bot token with a Bearer prefix will fail with a 401 even if the token value is correct. The GET /users/@me endpoint validates this format and returns the application object if valid, making it the canonical health probe.
async function discordHealthCheck(): Promise<{
healthy: boolean;
applicationId: string;
botUsername: string;
tokenFormatValid: boolean;
}> {
try {
const res = await fetch('https://discord.com/api/v10/users/@me', {
headers: {
'Authorization': `Bot ${DISCORD_BOT_TOKEN}` // NOT 'Bearer'
}
});
if (res.status === 401) {
const body = await res.json() as { code: number; message: string };
return {
healthy: false,
applicationId: '',
botUsername: '',
tokenFormatValid: body.code !== 0 // code 0 = auth failure; format issue = different code
};
}
const user = await res.json() as { id: string; username: string; discriminator: string };
return {
healthy: true,
applicationId: user.id,
botUsername: `${user.username}#${user.discriminator}`,
tokenFormatValid: true
};
} catch {
return { healthy: false, applicationId: '', botUsername: '', tokenFormatValid: false };
}
}
Twilio: account status validation
Twilio accounts have a status field that can be active, suspended, or closed. A suspended or closed account will return HTTP 401 or 403 on send requests, but the credentials themselves are still valid — the keys haven't been rotated, so a connectivity-only probe will report healthy. The correct health probe retrieves the account resource directly and checks the status field.
async function twilioHealthCheck(): Promise<{
healthy: boolean;
accountSid: string;
accountStatus: string;
friendlyName: string;
}> {
const account = await twilioClient.api
.accounts(TWILIO_ACCOUNT_SID)
.fetch();
const isHealthy = account.status === 'active';
return {
healthy: isHealthy,
accountSid: account.sid,
accountStatus: account.status,
friendlyName: account.friendlyName
};
}
SendGrid: GET /v3/scopes catches scope erosion
SendGrid's GET /v3/scopes endpoint is the only endpoint that returns the complete list of permissions granted to the current API key. This matters because SendGrid API keys support granular permission scoping at creation time, and those scopes can be modified by account administrators after the key is created. An API key that was granted mail.send at creation may have that permission revoked later — the key is still valid and will return 200 on GET /v3/user/profile, but will return 403 on POST /v3/mail/send. This is scope erosion, and only GET /v3/scopes detects it at health-check time.
async function sendgridHealthCheck(): Promise<{
healthy: boolean;
grantedScopes: string[];
missingScopes: string[];
}> {
const REQUIRED_SCOPES = ['mail.send', 'stats.read'];
const res = await fetch('https://api.sendgrid.com/v3/scopes', {
headers: {
'Authorization': `Bearer ${SENDGRID_API_KEY}`,
'Content-Type': 'application/json'
}
});
if (!res.ok) {
return { healthy: false, grantedScopes: [], missingScopes: REQUIRED_SCOPES };
}
const data = await res.json() as { scopes: string[] };
const missingScopes = REQUIRED_SCOPES.filter(s => !data.scopes.includes(s));
return {
healthy: missingScopes.length === 0,
grantedScopes: data.scopes,
missingScopes
};
}
PagerDuty: Token format validation
PagerDuty's authentication format uses a double-token convention: Authorization: Token token=KEY. The word "Token", then a space, then the literal string "token=", then the key value. This is different from every other platform in this arc. Sending Authorization: Bearer KEY or Authorization: TOKEN KEY will fail with 401 even if the key value is correct. The GET /users/me endpoint validates the exact header format and returns the authenticated user's role — which is essential for validating that the key has the right permission level for the operations your MCP server performs (some PagerDuty operations require Responder or higher; others require Admin).
async function pagerdutyHealthCheck(): Promise<{
healthy: boolean;
userId: string;
userEmail: string;
role: string;
hasRequiredRole: boolean;
}> {
const MINIMUM_ROLE = 'responder'; // adjust to 'manager' or 'admin' if needed
const ROLE_LEVELS: Record<string, number> = {
'limited_user': 0,
'user': 1,
'responder': 2,
'observer': 1,
'manager': 3,
'global_admin': 4,
'account_owner': 5
};
try {
const res = await fetch('https://api.pagerduty.com/users/me', {
headers: {
'Authorization': `Token token=${PAGERDUTY_API_KEY}`, // exact format required
'Accept': 'application/vnd.pagerduty+json;version=2'
}
});
if (!res.ok) {
return { healthy: false, userId: '', userEmail: '', role: '', hasRequiredRole: false };
}
const data = await res.json() as { user: { id: string; email: string; role: string } };
const role = data.user.role;
const hasRequiredRole = (ROLE_LEVELS[role] ?? 0) >= (ROLE_LEVELS[MINIMUM_ROLE] ?? 0);
return {
healthy: hasRequiredRole,
userId: data.user.id,
userEmail: data.user.email,
role,
hasRequiredRole
};
} catch {
return { healthy: false, userId: '', userEmail: '', role: '', hasRequiredRole: false };
}
}
Cross-cutting concern: authentication format diversity
The five communication platforms in this arc use five structurally different HTTP authentication formats. This is not a minor difference in header values — it is a difference in the format of the Authorization header itself. A generic credential injector that assumes one format will silently fail on the platforms that use a different format, producing authentication errors that look like network errors to a naive implementation.
See the MCP authentication primer for the broader context on credential management; the table below focuses on the specific format divergence across communication platforms:
| Platform | Authorization header format | Token prefix | Auth mechanism |
|---|---|---|---|
| Slack | Authorization: Bearer xoxb-... |
Bearer |
Bot token (xoxb-) or user token (xoxp-) |
| Discord | Authorization: Bot MTc... |
Bot (NOT Bearer) |
Bot token only; OAuth uses Bearer |
| Twilio | HTTP Basic Auth: AccountSid:AuthToken |
None (Basic Auth) | Account SID + Auth Token in base64 |
| SendGrid | Authorization: Bearer SG.xxxx |
Bearer |
API key with optional scopes |
| PagerDuty | Authorization: Token token=KEY |
Token token= |
API key; separate OAuth for user tokens |
The trap in multi-platform MCP servers — where a single server wraps all five platforms — is a shared addAuthHeader helper that uses a fixed format. The correct approach is a per-platform credential factory:
type Platform = 'slack' | 'discord' | 'twilio' | 'sendgrid' | 'pagerduty';
function getAuthHeaders(platform: Platform): Record<string, string> {
switch (platform) {
case 'slack':
return { 'Authorization': `Bearer ${process.env.SLACK_BOT_TOKEN}` };
case 'discord':
// NOTE: 'Bot' prefix is required; 'Bearer' will fail
return { 'Authorization': `Bot ${process.env.DISCORD_BOT_TOKEN}` };
case 'twilio':
// Basic Auth: base64(AccountSid:AuthToken)
const credentials = Buffer.from(
`${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`
).toString('base64');
return { 'Authorization': `Basic ${credentials}` };
case 'sendgrid':
return { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` };
case 'pagerduty':
// NOTE: 'Token token=' prefix (double-token) is required
return { 'Authorization': `Token token=${process.env.PAGERDUTY_API_KEY}` };
}
}
Centralizing credential format in a switch statement ensures the format is validated at startup — if platform is ever passed a value not in the union type, TypeScript catches it at compile time. The alternative, embedding format strings at each call site, means a copy-paste error produces an authentication failure that is indistinguishable from a credential rotation issue.
If you are building a unified communication platform MCP server, also centralize the base URLs: Slack is https://slack.com/api/, Discord is https://discord.com/api/v10, Twilio is https://api.twilio.com/2010-04-01, SendGrid is https://api.sendgrid.com/v3, and PagerDuty is https://api.pagerduty.com. These are sufficiently different that a shared base-URL variable makes no sense — centralize the base URL per platform alongside the credential format.
Putting it together: a composite health check
A production communication platform MCP server running all five integrations should expose a single health endpoint that aggregates all five platform checks in parallel, with a 5-second timeout per check. The response should distinguish between "credential expired or revoked" (unhealthy, recoverable by rotating the key) and "scope missing or account suspended" (unhealthy, requires operator action beyond key rotation).
interface PlatformHealth {
platform: string;
healthy: boolean;
reason?: string;
latencyMs: number;
}
async function compositeCommunicationHealthCheck(): Promise<{
allHealthy: boolean;
platforms: PlatformHealth[];
checkedAt: string;
}> {
const TIMEOUT_MS = 5000;
const withTimeout = <T>(promise: Promise<T>, platform: string): Promise<T> =>
Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`${platform} health check timed out`)), TIMEOUT_MS)
)
]);
const checks = await Promise.allSettled([
withTimeout(slackHealthCheck(), 'slack'),
withTimeout(discordHealthCheck(), 'discord'),
withTimeout(twilioHealthCheck(), 'twilio'),
withTimeout(sendgridHealthCheck(), 'sendgrid'),
withTimeout(pagerdutyHealthCheck(), 'pagerduty')
]);
const platforms: PlatformHealth[] = checks.map((result, i) => {
const name = ['slack', 'discord', 'twilio', 'sendgrid', 'pagerduty'][i];
if (result.status === 'fulfilled') {
return {
platform: name,
healthy: result.value.healthy,
latencyMs: 0 // add timing wrapper in production
};
}
return {
platform: name,
healthy: false,
reason: result.reason?.message ?? 'unknown error',
latencyMs: TIMEOUT_MS
};
});
return {
allHealthy: platforms.every(p => p.healthy),
platforms,
checkedAt: new Date().toISOString()
};
}
For MCP server health monitoring, this composite check should be exposed as a dedicated tool alongside the individual platform tools, rather than being called automatically on every request. The overhead of five sequential — or even parallel — HTTP requests adds meaningful latency to tool initialization if called on every invocation.
Summary
Communication platform MCP integrations fail in predictable ways. The three patterns in this article cover the most common production failure modes across all five major platforms:
- The confirm guard is not optional for any of these platforms. Every send operation is irreversible — Twilio's 201 Created, SendGrid's 202 Accepted, PagerDuty's immediate escalation, and Slack and Discord's push notification delivery all represent points of no return. Implement a preview/confirm tool pair before each send.
- Rate limit diversity means a single retry interceptor cannot be correct for all five platforms simultaneously. Slack uses per-method tiers with a
Retry-Afterheader; Discord uses per-route buckets withX-RateLimit-Reset-After; Twilio uses carrier-level throttling that manifests as error codes rather than 429 responses; SendGrid'sX-RateLimit-Resetis a Unix timestamp; PagerDuty'sX-RateLimit-Resetis seconds-from-now. These require per-platform retry logic, not a shared interceptor. - Health probe scope validation requires a platform-specific endpoint for each integration.
auth.testfor Slack,GET /users/@mefor Discord,GET /Accounts/:AccountSidfor Twilio,GET /v3/scopesfor SendGrid, andGET /users/mefor PagerDuty — each validates credentials, format, and scope simultaneously, while the common wrong probe validates only connectivity.
The authentication format diversity table is the simplest thing to get wrong and the simplest to fix with a per-platform factory function. Discord's Bot prefix, Twilio's Basic Auth, and PagerDuty's Token token= double-prefix are the three most common copy-paste errors when adapting code from one platform to another.
For the full per-platform reference, see: Slack MCP server reference, Discord MCP server reference, Twilio MCP server reference, SendGrid MCP server reference, and PagerDuty MCP server reference.