Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ dist/
*.local
coverage/
*.tsbuildinfo
.mcpregistry_github_token
.mcpregistry_registry_token
54 changes: 36 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

**[Runframe](https://runframe.io)** is Slack-native incident management & on-call scheduling for engineering teams. This MCP server lets you manage the full incident lifecycle from your IDE or AI agent.

16 tools covering incidents, on-call, services, postmortems, and teams. Requires Node.js 20+.
17 tools covering incidents, on-call, services, postmortems, teams, and people lookup. Requires Node.js 20+.

## Why Use This

Expand All @@ -31,11 +31,12 @@ The server is stateless. It translates MCP tool calls into Runframe API requests

Ask your agent:

- *"Acknowledge incident INC-42"* → calls `runframe_acknowledge_incident`
- *"Acknowledge incident INC-2026-001"* → calls `runframe_acknowledge_incident`
- *"Who is on call right now?"* → calls `runframe_get_current_oncall`
- *"Create a postmortem for the database outage"* → calls `runframe_create_postmortem`
- *"Page the backend team lead about the API latency spike"* → calls `runframe_page_someone`
- *"List all open SEV1 incidents"* → calls `runframe_list_incidents` with severity filter
- *"Find Niketa so I can check her open incidents"* → calls `runframe_find_user`

## Install

Expand Down Expand Up @@ -105,42 +106,59 @@ The server stores nothing. It is a pass-through to the Runframe API.

| Tool | Scopes | Description |
|------|--------|-------------|
| `runframe_list_incidents` | `read:incidents` | List incidents with filters and pagination |
| `runframe_get_incident` | `read:incidents` | Get incident by ID or number |
| `runframe_create_incident` | `write:incidents` | Create an incident |
| `runframe_update_incident` | `write:incidents` | Update title, description, severity, or assignment |
| `runframe_change_incident_status` | `write:incidents` | Move to a new status (new, investigating, fixing, monitoring, resolved, closed) |
| `runframe_acknowledge_incident` | `write:incidents` | Acknowledge (auto-assigns, tracks SLA) |
| `runframe_add_incident_event` | `write:incidents` | Add a timeline entry |
| `runframe_escalate_incident` | `write:incidents` | Escalate to the next policy level |
| `runframe_page_someone` | `write:incidents` | Page a responder via Slack or email |
| `runframe_list_incidents` | `incidents:read` | List incidents with filters and pagination |
| `runframe_get_incident` | `incidents:read` | Get incident by ID or number |
| `runframe_create_incident` | `incidents:write` | Create an incident |
| `runframe_update_incident` | `incidents:write` | Update title, description, severity, or assignment |
| `runframe_change_incident_status` | `incidents:write` | Move to a new status (new, investigating, fixing, monitoring, resolved, closed) |
| `runframe_acknowledge_incident` | `incidents:write` | Acknowledge (auto-assigns, tracks SLA) |
| `runframe_add_incident_event` | `incidents:write` | Add a timeline entry |
| `runframe_escalate_incident` | `incidents:write` | Escalate to the next policy level |
| `runframe_page_someone` | `incidents:write` | Page a responder via Slack or email |

### On-call (1)

| Tool | Scopes | Description |
|------|--------|-------------|
| `runframe_get_current_oncall` | `read:oncall` | Who is on call right now |
| `runframe_get_current_oncall` | `oncall:read` | Who is on call right now |

### Services (2)

| Tool | Scopes | Description |
|------|--------|-------------|
| `runframe_list_services` | `read:services` | List services |
| `runframe_get_service` | `read:services` | Get service details |
| `runframe_list_services` | `services:read` | List services |
| `runframe_get_service` | `services:read` | Get service details |

### Postmortems (2)

| Tool | Scopes | Description |
|------|--------|-------------|
| `runframe_create_postmortem` | `write:postmortems` | Create a postmortem |
| `runframe_get_postmortem` | `read:postmortems` | Get postmortem for an incident |
| `runframe_create_postmortem` | `postmortems:write` | Create a postmortem |
| `runframe_get_postmortem` | `postmortems:read` | Get postmortem for an incident |

### Teams (2)

| Tool | Scopes | Description |
|------|--------|-------------|
| `runframe_list_teams` | `read:teams` | List teams |
| `runframe_get_escalation_policy` | `read:oncall` | Get escalation policy for a severity level |
| `runframe_list_teams` | `teams:read` | List teams |
| `runframe_get_escalation_policy` | `oncall:read` | Get escalation policy for a severity level |

### Users (1)

| Tool | Scopes | Description |
|------|--------|-------------|
| `runframe_find_user` | `users:read` | Search active users by name or email |

## Direct API alignment

This MCP server follows the public Runframe direct API contract.

- Incident create requires `service_ids` containing public service keys like `SER-00001`, not internal UUIDs.
- Incident IDs in tools may be either UUIDs or incident numbers like `INC-2026-001`.
- `runframe_create_incident` accepts an optional `idempotency_key`, which is forwarded as the `Idempotency-Key` header for retry-safe creates.
- Use `runframe_list_services` to discover valid `service_key` values before creating incidents.
- Use `runframe_find_user` to resolve a person name before filtering incidents by `assigned_to` or `resolved_by`.
- Use `runframe_list_teams` with `search` to resolve a team name before filtering incidents by `team_id`.

## Docker

Expand Down
26 changes: 24 additions & 2 deletions src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ describe('RunframeClient', () => {
assert.strictEqual(typeof client.post, 'function');
assert.strictEqual(typeof client.patch, 'function');
});

it('supports per-request headers', async () => {
let seenHeaders: Headers | undefined;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
seenHeaders = new Headers(init?.headers);
return new Response(JSON.stringify({ data: { ok: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof fetch;

try {
const client = new RunframeClient({ apiKey: 'rf_test', apiUrl: 'https://example.com' });
await client.post('/api/v1/incidents', { title: 'Test', service_ids: ['SER-00001'] }, {
headers: { 'Idempotency-Key': 'incident-create-001' },
});
assert.strictEqual(seenHeaders?.get('Idempotency-Key'), 'incident-create-001');
} finally {
globalThis.fetch = originalFetch;
}
});
});

// ── RunframeApiError ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -148,7 +170,7 @@ describe('createServer', () => {
// ── Tool count verification ──────────────────────────────────────────────

describe('tool registration', () => {
it('registers exactly 16 tools', async () => {
it('registers exactly 17 tools', async () => {
const client = new RunframeClient({ apiKey: 'rf_test', apiUrl: 'https://example.com' });
const server = createServer(client);

Expand All @@ -164,7 +186,7 @@ describe('tool registration', () => {
await mcpClient.connect(clientTransport);

const { tools } = await mcpClient.listTools();
assert.strictEqual(tools.length, 16, `Expected 16 tools, got ${tools.length}: ${tools.map(t => t.name).join(', ')}`);
assert.strictEqual(tools.length, 17, `Expected 17 tools, got ${tools.length}: ${tools.map(t => t.name).join(', ')}`);

await mcpClient.close();
await server.close();
Expand Down
74 changes: 66 additions & 8 deletions src/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface CapturedCall {
method: string;
path: string;
body?: Record<string, unknown>;
headers?: Record<string, string>;
}

class MockRunframeClient extends RunframeClient {
Expand All @@ -26,8 +27,13 @@ class MockRunframeClient extends RunframeClient {
super({ apiKey: 'rf_test_key', apiUrl: 'https://mock.runframe.io' });
}

override async request<T>(method: string, path: string, body?: Record<string, unknown>): Promise<T> {
this.calls.push({ method, path, body });
override async request<T>(
method: string,
path: string,
body?: Record<string, unknown>,
options?: { headers?: Record<string, string> }
): Promise<T> {
this.calls.push({ method, path, body, headers: options?.headers });
return this.mockResponse as T;
}

Expand Down Expand Up @@ -109,6 +115,20 @@ describe('incident tools', () => {
assert.ok(call.path.includes('offset=10'));
});

it('includes assignee, resolver, and date range filters', async () => {
await callTool(mcpClient, 'runframe_list_incidents', {
assigned_to: '11111111-1111-4111-8111-111111111111',
resolved_by: '22222222-2222-4222-8222-222222222222',
created_after: '2026-04-01T00:00:00.000Z',
resolved_before: '2026-04-30T23:59:59.999Z',
});
const call = mock.lastCall();
assert.ok(call.path.includes('assigned_to=11111111-1111-4111-8111-111111111111'));
assert.ok(call.path.includes('resolved_by=22222222-2222-4222-8222-222222222222'));
assert.ok(call.path.includes('created_after=2026-04-01T00%3A00%3A00.000Z'));
assert.ok(call.path.includes('resolved_before=2026-04-30T23%3A59%3A59.999Z'));
});

it('handles offset=0 correctly (not dropped)', async () => {
await callTool(mcpClient, 'runframe_list_incidents', { limit: 20, offset: 0 });
const call = mock.lastCall();
Expand Down Expand Up @@ -136,27 +156,32 @@ describe('incident tools', () => {
describe('runframe_create_incident', () => {
it('POSTs to correct endpoint with full body', async () => {
mock.reset({ id: 'new-id', incident_number: 'INC-2026-033' });
const serviceId = 'd804e776-b29f-474e-a377-fc5e6b31c2de';
const serviceKey = 'SER-00001';
await callTool(mcpClient, 'runframe_create_incident', {
title: 'Redis Cache Storm',
description: 'Cache eviction on prod-03',
severity: 'SEV1',
service_ids: [serviceId],
service_ids: [serviceKey],
});
const call = mock.lastCall();
assert.strictEqual(call.method, 'POST');
assert.strictEqual(call.path, '/api/v1/incidents');
assert.strictEqual(call.body?.title, 'Redis Cache Storm');
assert.strictEqual(call.body?.description, 'Cache eviction on prod-03');
assert.strictEqual(call.body?.severity, 'SEV1');
assert.deepStrictEqual(call.body?.service_ids, [serviceId]);
assert.deepStrictEqual(call.body?.service_ids, [serviceKey]);
});

it('works with title only (minimum required)', async () => {
it('sends Idempotency-Key header when provided', async () => {
mock.reset({ id: 'new-id' });
await callTool(mcpClient, 'runframe_create_incident', { title: 'Minimal incident' });
await callTool(mcpClient, 'runframe_create_incident', {
title: 'Redis Cache Storm',
service_ids: ['SER-00001'],
idempotency_key: 'incident-create-001',
});
const call = mock.lastCall();
assert.strictEqual(call.body?.title, 'Minimal incident');
assert.strictEqual(call.headers?.['Idempotency-Key'], 'incident-create-001');
assert.strictEqual(call.body?.idempotency_key, undefined);
});
});

Expand Down Expand Up @@ -412,6 +437,12 @@ describe('team tools', () => {
});

describe('runframe_list_teams', () => {
it('includes search when provided', async () => {
await callTool(mcpClient, 'runframe_list_teams', { search: 'platform' });
const call = mock.lastCall();
assert.ok(call.path.includes('search=platform'));
});

it('GETs teams with pagination', async () => {
await callTool(mcpClient, 'runframe_list_teams', { limit: 50, offset: 10 });
const call = mock.lastCall();
Expand All @@ -438,6 +469,33 @@ describe('team tools', () => {
});
});

// ── User tools ───────────────────────────────────────────────────────────

describe('user tools', () => {
let mock: MockRunframeClient;
let mcpClient: Client;

beforeEach(async () => {
mock = new MockRunframeClient();
mock.reset({ items: [], total: 0, has_more: false, next_offset: null });
const setup = await setupServer(mock);
mcpClient = setup.mcpClient;
});

describe('runframe_find_user', () => {
it('GETs active users with search and pagination defaults', async () => {
await callTool(mcpClient, 'runframe_find_user', { search: 'niketa' });
const call = mock.lastCall();
assert.strictEqual(call.method, 'GET');
assert.ok(call.path.startsWith('/api/v1/users?'));
assert.ok(call.path.includes('search=niketa'));
assert.ok(call.path.includes('is_active=true'));
assert.ok(call.path.includes('limit=10'));
assert.ok(call.path.includes('offset=0'));
});
});
});

// ── Error handling across tools ──────────────────────────────────────────

describe('tool error handling', () => {
Expand Down
14 changes: 8 additions & 6 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { VERSION } from './types.js';
import type { RunframeConfig, ApiErrorResponse } from './types.js';
import type { RunframeConfig, ApiErrorResponse, RequestOptions } from './types.js';

const REQUEST_TIMEOUT_MS = 15_000;

Expand All @@ -15,13 +15,15 @@ export class RunframeClient {
async request<T>(
method: string,
path: string,
body?: Record<string, unknown>
body?: Record<string, unknown>,
options?: RequestOptions
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'User-Agent': `runframe-mcp-server/${VERSION}`,
...(options?.headers ?? {}),
};

const controller = new AbortController();
Expand Down Expand Up @@ -73,12 +75,12 @@ export class RunframeClient {
return this.request<T>('GET', path);
}

post<T>(path: string, body: Record<string, unknown>): Promise<T> {
return this.request<T>('POST', path, body);
post<T>(path: string, body: Record<string, unknown>, options?: RequestOptions): Promise<T> {
return this.request<T>('POST', path, body, options);
}

patch<T>(path: string, body: Record<string, unknown>): Promise<T> {
return this.request<T>('PATCH', path, body);
patch<T>(path: string, body: Record<string, unknown>, options?: RequestOptions): Promise<T> {
return this.request<T>('PATCH', path, body, options);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerOncallTools } from './tools/oncall.js';
import { registerServiceTools } from './tools/services.js';
import { registerPostmortemTools } from './tools/postmortems.js';
import { registerTeamTools } from './tools/teams.js';
import { registerUserTools } from './tools/users.js';

export function createServer(client: RunframeClient): McpServer {
const server = new McpServer({
Expand All @@ -19,6 +20,7 @@ export function createServer(client: RunframeClient): McpServer {
registerServiceTools(server, client);
registerPostmortemTools(server, client);
registerTeamTools(server, client);
registerUserTools(server, client);

return server;
}
Expand Down
20 changes: 18 additions & 2 deletions src/tools/incidents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ export function registerIncidentTools(server: McpServer, client: RunframeClient)
inputSchema: {
status: z.array(z.string()).optional().describe('Filter by status name. Default statuses: new, investigating, fixing, monitoring, resolved, closed (may vary by organization)'),
severity: z.array(z.string()).optional().describe('Filter by severity: SEV0-SEV4'),
assigned_to: z.string().uuid().optional().describe('Filter by current assignee UUID'),
resolved_by: z.string().uuid().optional().describe('Filter by resolver UUID'),
team_id: z.string().uuid().optional().describe('Filter by team UUID'),
created_after: z.string().datetime().optional().describe('Only incidents created at or after this ISO timestamp'),
created_before: z.string().datetime().optional().describe('Only incidents created at or before this ISO timestamp'),
resolved_after: z.string().datetime().optional().describe('Only incidents resolved at or after this ISO timestamp'),
resolved_before: z.string().datetime().optional().describe('Only incidents resolved at or before this ISO timestamp'),
limit: z.number().min(1).max(100).default(20).describe('Results per page (max 100)'),
offset: z.number().min(0).default(0).describe('Pagination offset'),
},
Expand All @@ -26,7 +32,13 @@ export function registerIncidentTools(server: McpServer, client: RunframeClient)
if (params.offset != null) query.set('offset', String(params.offset));
params.status?.forEach((s) => query.append('status', s));
params.severity?.forEach((s) => query.append('severity', s));
if (params.assigned_to) query.set('assigned_to', params.assigned_to);
if (params.resolved_by) query.set('resolved_by', params.resolved_by);
if (params.team_id) query.set('team_id', params.team_id);
if (params.created_after) query.set('created_after', params.created_after);
if (params.created_before) query.set('created_before', params.created_before);
if (params.resolved_after) query.set('resolved_after', params.resolved_after);
if (params.resolved_before) query.set('resolved_before', params.resolved_before);
const data = await client.get(`/api/v1/incidents?${query}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error) { return toolError(error, 'runframe_list_incidents'); }
Expand All @@ -53,12 +65,16 @@ export function registerIncidentTools(server: McpServer, client: RunframeClient)
title: z.string().max(200).describe('Incident title (required, max 200 chars)'),
description: z.string().optional().describe('Detailed description'),
severity: z.string().optional().describe('SEV0-SEV4, defaults to org setting'),
service_ids: z.array(z.string().uuid()).optional().describe('Affected service UUIDs'),
service_ids: z.array(z.string()).min(1).describe('Affected public service keys (for example SER-00001). Discover keys via runframe_list_services.'),
idempotency_key: z.string().optional().describe('Optional retry-safe idempotency key for create requests. Same key + same payload replays the original response.'),
},
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
}, async (params) => {
try {
const data = await client.post('/api/v1/incidents', params);
const { idempotency_key, ...body } = params;
const data = await client.post('/api/v1/incidents', body, {
headers: idempotency_key ? { 'Idempotency-Key': idempotency_key } : undefined,
});
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error) { return toolError(error, 'runframe_create_incident'); }
});
Expand Down
Loading
Loading