Guide · MCP PagerDuty Integration
MCP Server PagerDuty — create incidents, lifecycle management, on-call schedules, and /health via GET /users/me
PagerDuty is the central on-call management platform for operations teams — the destination for agent-generated alerts that need human intervention. This guide covers building TypeScript MCP tools for the PagerDuty REST API v2: Token authentication with the unique header format, creating incidents with urgency levels and confirm guards, managing the triggered → acknowledged → resolved lifecycle, escalating incidents to next responders, listing on-call schedules to find who's available, and wiring a /health/pagerduty endpoint that calls GET /users/me before AliveMCP detects silent authentication failures.
TL;DR
PagerDuty uses a non-standard token format: Authorization: Token token=YOUR_API_KEY — not Bearer. All requests require Accept: application/vnd.pagerduty+json;version=2 for the v2 API. Rate limit is 900 requests/minute (15 req/s) tracked via X-RateLimit-* headers. Creating an incident fires pages to on-call responders immediately — always add a confirm: z.literal(true) guard. The incident lifecycle state machine is: triggered → acknowledged → resolved. Each service has its own ID — look up service IDs before calling create_incident. Health: GET /users/me validates the API key; do not use GET /abilities which may return 200 even for invalid keys in some configurations.
SDK setup and authentication
PagerDuty doesn't have an official Node.js SDK with full TypeScript support. Use axios with an interceptor that applies the required authentication and content-type headers to every request. The two-part Authorization: Token token=VALUE format is PagerDuty-specific and easy to get wrong.
import axios from 'axios';
import { z } from 'zod';
// PagerDuty token format: Token token=YOUR_API_KEY_HERE
// NOT: Bearer YOUR_API_KEY_HERE
// NOT: Token YOUR_API_KEY_HERE (without the second "token=" prefix)
const pdHttp = axios.create({
baseURL: 'https://api.pagerduty.com',
headers: {
// Required for PagerDuty REST API v2
'Accept': 'application/vnd.pagerduty+json;version=2',
'Content-Type': 'application/json',
'Authorization': `Token token=${process.env.PAGERDUTY_API_KEY}`
}
});
// Rate limit tracking from response headers:
// X-RateLimit-Limit: 900 (requests per minute)
// X-RateLimit-Remaining: 895 (remaining this minute)
// X-RateLimit-Reset: 60 (seconds until window resets)
pdHttp.interceptors.response.use(
res => res,
async err => {
if (err.response?.status === 429) {
const resetSeconds = Number(err.response.headers['x-ratelimit-reset'] ?? 60);
await new Promise(resolve => setTimeout(resolve, resetSeconds * 1000));
return pdHttp.request(err.config);
}
return Promise.reject(err);
}
);
| Header | Value | Required |
|---|---|---|
Authorization |
Token token=YOUR_KEY |
Yes — wrong format causes 401 |
Accept |
application/vnd.pagerduty+json;version=2 |
Yes — omitting returns v1 schema with different field names |
Content-Type |
application/json |
Yes for POST/PUT — PagerDuty returns 415 without it |
From |
Email of the acting user | Required for incident creation, acknowledgment, and resolution |
The From header must be the email of a valid user in your PagerDuty account. For automated MCP tools, create a dedicated service account user (e.g. mcp-agent@yourdomain.com) — this separates automated activity from human responses in the incident timeline.
Creating incidents with urgency levels and confirm guards
Creating a PagerDuty incident immediately pages the on-call responders for the specified service. This has real-world consequences — people receive phone calls and SMS alerts at any hour. Always require explicit confirmation and validate the service ID before creating an incident.
server.tool('create_pagerduty_incident', {
title: z.string().min(1).max(1024).describe('Incident title — shown in the alert and page.'),
service_id: z.string().describe('PagerDuty service ID (P...). Run list_pagerduty_services first.'),
urgency: z.enum(['high', 'low']).describe('"high" = pages on-call immediately. "low" = queued by support hours.'),
body: z.string().optional().describe('Additional incident details shown in the PagerDuty web UI.'),
escalation_policy_id: z.string().optional().describe('Override service default escalation policy.'),
from_email: z.string().email().describe('Email of the PagerDuty user creating the incident.'),
confirm: z.literal(true).describe('This will page on-call responders immediately. Pass true to confirm.')
}, async ({ title, service_id, urgency, body, escalation_policy_id, from_email }) => {
const payload: Record = {
incident: {
type: 'incident',
title,
service: { id: service_id, type: 'service_reference' },
urgency,
...(body ? { body: { type: 'incident_body', details: body } } : {}),
...(escalation_policy_id ? {
escalation_policy: { id: escalation_policy_id, type: 'escalation_policy_reference' }
} : {})
}
};
const res = await pdHttp.post('/incidents', payload, {
headers: { From: from_email }
});
const incident = res.data.incident;
return {
content: [{
type: 'text',
text: JSON.stringify({
id: incident.id,
number: incident.incident_number,
title: incident.title,
status: incident.status,
urgency: incident.urgency,
html_url: incident.html_url,
created_at: incident.created_at
}, null, 2)
}]
};
});
Listing incidents with status filters
The GET /incidents endpoint supports comprehensive filtering. Pass statuses[] as a repeated query parameter — the API uses array notation rather than comma-separated values. Time range filters use ISO 8601 timestamps.
server.tool('list_pagerduty_incidents', {
statuses: z.array(z.enum(['triggered', 'acknowledged', 'resolved']))
.default(['triggered', 'acknowledged'])
.describe('Filter by incident status. Defaults to all open incidents.'),
service_ids: z.array(z.string()).optional().describe('Filter to specific service IDs.'),
since: z.string().optional().describe('ISO 8601 datetime — incidents created after this time.'),
until: z.string().optional().describe('ISO 8601 datetime — incidents created before this time.'),
limit: z.number().int().min(1).max(100).default(25),
offset: z.number().int().min(0).default(0)
}, async ({ statuses, service_ids, since, until, limit, offset }) => {
const params = new URLSearchParams();
// PagerDuty uses array notation: statuses[]=triggered&statuses[]=acknowledged
statuses.forEach(s => params.append('statuses[]', s));
if (service_ids) service_ids.forEach(id => params.append('service_ids[]', id));
if (since) params.set('since', since);
if (until) params.set('until', until);
params.set('limit', String(limit));
params.set('offset', String(offset));
params.set('sort_by', 'created_at:desc');
const res = await pdHttp.get(`/incidents?${params}`);
const { incidents, more, total } = res.data;
return {
content: [{
type: 'text',
text: JSON.stringify({
total,
more,
incidents: incidents.map((i: any) => ({
id: i.id,
number: i.incident_number,
title: i.title,
status: i.status,
urgency: i.urgency,
service: i.service?.summary,
assigned_to: i.assignments?.map((a: any) => a.assignee?.summary),
created_at: i.created_at,
html_url: i.html_url
}))
}, null, 2)
}]
};
});
Incident lifecycle — acknowledge and resolve
PagerDuty's incident state machine moves through three states: triggered (open, paging), acknowledged (someone is working on it, paging stopped), and resolved (closed). Both acknowledge and resolve use PUT /incidents/{id} with a different status value in the request body.
server.tool('update_pagerduty_incident', {
incident_id: z.string().describe('PagerDuty incident ID.'),
action: z.enum(['acknowledge', 'resolve']),
from_email: z.string().email().describe('Email of the PagerDuty user performing the action.'),
resolution_note: z.string().optional().describe('Note added to timeline when resolving.')
}, async ({ incident_id, action, from_email, resolution_note }) => {
const body = {
incident: {
type: 'incident',
status: action === 'acknowledge' ? 'acknowledged' : 'resolved'
}
};
const res = await pdHttp.put(`/incidents/${incident_id}`, body, {
headers: { From: from_email }
});
const updated = res.data.incident;
// Add resolution note via the notes endpoint if provided
if (resolution_note && action === 'resolve') {
await pdHttp.post(`/incidents/${incident_id}/notes`, {
note: { content: resolution_note }
}, { headers: { From: from_email } });
}
return {
content: [{
type: 'text',
text: JSON.stringify({
id: updated.id,
status: updated.status,
resolved_at: updated.resolved_at,
last_status_change_at: updated.last_status_change_at
}, null, 2)
}]
};
});
| Transition | From status | To status | Effect |
|---|---|---|---|
| Acknowledge | triggered |
acknowledged |
Stops escalation paging; responder takes ownership |
| Resolve | triggered or acknowledged |
resolved |
Closes the incident; no more notifications sent |
| Re-trigger | resolved |
triggered |
Not possible via API — create a new incident instead |
Escalating incidents and listing on-call responders
When the current responder cannot handle an incident, escalate to the next level in the escalation policy. The escalate endpoint bypasses the normal timer-based escalation and immediately pages the next responder. To find who's currently on call, query the /oncalls endpoint filtered by a time window.
server.tool('escalate_pagerduty_incident', {
incident_id: z.string().describe('PagerDuty incident ID to escalate.'),
escalation_level: z.number().int().min(1).describe('Escalation policy level to escalate to (1-indexed).'),
from_email: z.string().email(),
confirm: z.literal(true).describe('This immediately pages the next on-call responder. Confirm.')
}, async ({ incident_id, escalation_level, from_email }) => {
const res = await pdHttp.post(`/incidents/${incident_id}/escalate`, {
escalation_level
}, { headers: { From: from_email } });
return {
content: [{
type: 'text',
text: `Escalated to level ${escalation_level}. Status: ${res.data.incident.status}`
}]
};
});
server.tool('list_pagerduty_oncall', {
schedule_ids: z.array(z.string()).optional().describe('Filter to specific schedule IDs.'),
since: z.string().optional().describe('Start of on-call window (ISO 8601). Defaults to now.'),
until: z.string().optional().describe('End of on-call window (ISO 8601). Defaults to now + 1 hour.')
}, async ({ schedule_ids, since, until }) => {
const now = new Date().toISOString();
const params = new URLSearchParams({
since: since ?? now,
until: until ?? new Date(Date.now() + 3_600_000).toISOString()
});
if (schedule_ids) schedule_ids.forEach(id => params.append('schedule_ids[]', id));
const res = await pdHttp.get(`/oncalls?${params}`);
return {
content: [{
type: 'text',
text: JSON.stringify({
oncalls: res.data.oncalls.map((o: any) => ({
user_name: o.user?.summary,
user_email: o.user?.email,
schedule: o.schedule?.summary,
escalation_policy: o.escalation_policy?.summary,
escalation_level: o.escalation_level,
start: o.start,
end: o.end
}))
}, null, 2)
}]
};
});
Wiring /health/pagerduty via GET /users/me
GET /users/me is the correct PagerDuty health probe. It validates the API token and returns the user account the token belongs to. Avoid GET /abilities as a health probe — it can return HTTP 200 for unauthenticated or poorly scoped tokens.
app.get('/health/pagerduty', async (req, res) => {
try {
const response = await pdHttp.get('/users/me');
const user = response.data.user;
return res.json({
status: 'healthy',
user_id: user.id,
name: user.name,
email: user.email,
role: user.role, // 'admin', 'user', 'read_only_user', 'observer'
time_zone: user.time_zone
});
} catch (err: any) {
const status = err.response?.status;
const pdError = err.response?.data?.error;
return res.status(503).json({
status: 'unhealthy',
http_status: status,
error: pdError?.message ?? err.message,
code: pdError?.code
});
}
});
| HTTP status | PagerDuty error code | Meaning |
|---|---|---|
| 401 | 2006 | Invalid API key or wrong Authorization header format (Token token= required) |
| 403 | 2007 | Forbidden — account suspended or key lacks permission |
| 429 | 2020 | Rate limited — 900 req/min exceeded; wait for X-RateLimit-Reset |
| 500 | — | PagerDuty internal error — transient, retry with exponential backoff |
Frequently asked questions
What's the correct Authorization header format for PagerDuty?
PagerDuty requires Authorization: Token token=YOUR_API_KEY — two tokens in the header value. The first word is Token (the auth scheme), followed by the key-value pair token=YOUR_KEY. This is different from Bearer authentication (Authorization: Bearer key) and HTTP Basic Auth. Getting this wrong causes 401 errors with error code 2006. Additionally, you must include Accept: application/vnd.pagerduty+json;version=2 — without it, requests return v1 schema responses with different field names and structure, or fail outright.
Why do I need the From header for incident creation?
PagerDuty requires a From header (the email address of the user performing the action) for write operations on incidents: create, acknowledge, resolve, escalate, and note creation. This creates an audit trail in the incident timeline showing which user (or service account) triggered each state change. The email must belong to a valid user in your PagerDuty account. For automated MCP tools, create a dedicated service account user (e.g. mcp-agent@yourdomain.com) and use that email consistently — it separates automated activity from human responses in the timeline.
How do I find the service ID to create an incident?
Service IDs look like P3RXTXZ — alphanumeric with a P prefix. Find them via GET /services, in the PagerDuty web UI URL when viewing a service, or by building a list_pagerduty_services MCP tool that returns name + ID pairs. Always expose list_pagerduty_services alongside create_incident — the agent needs to discover valid service IDs before it can create incidents. Never hardcode service IDs in the tool description; they differ between PagerDuty accounts.
What's the difference between high and low urgency incidents?
Urgency controls how and when on-call responders are paged. high urgency incidents page responders immediately at any hour — phone calls, SMS, and push notifications regardless of support hours or personal notification rules. low urgency incidents are queued according to each service's support hours configuration: they only notify during business hours or with lower-priority methods like email. For agent-generated alerts about active production failures, use high. For informational incidents or soft warnings where immediate human response isn't required, use low to respect on-call responders' off-hours time and reduce alert fatigue.
Further reading
- MCP Server Slack — Block Kit messages, cursor pagination, and health probes via auth.test
- MCP Server Discord — Bot token auth, embed messages, and guild member management
- MCP Server SendGrid — transactional email, bounce management, and health probes via /scopes
- MCP Server Health Check — wiring /health endpoints for uptime monitoring
- Monitoring MCP Servers — alerting, uptime checks, and observability patterns