diff --git a/.gitignore b/.gitignore index ffcc8fc..c4a7f50 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/ *.local coverage/ *.tsbuildinfo +.mcpregistry_github_token +.mcpregistry_registry_token diff --git a/README.md b/README.md index 1272e6a..c1c9bd1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 0b55584..cbf43cb 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -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 ───────────────────────────────────────────────────── @@ -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); @@ -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(); diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 8108894..7541c81 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -16,6 +16,7 @@ interface CapturedCall { method: string; path: string; body?: Record; + headers?: Record; } class MockRunframeClient extends RunframeClient { @@ -26,8 +27,13 @@ class MockRunframeClient extends RunframeClient { super({ apiKey: 'rf_test_key', apiUrl: 'https://mock.runframe.io' }); } - override async request(method: string, path: string, body?: Record): Promise { - this.calls.push({ method, path, body }); + override async request( + method: string, + path: string, + body?: Record, + options?: { headers?: Record } + ): Promise { + this.calls.push({ method, path, body, headers: options?.headers }); return this.mockResponse as T; } @@ -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(); @@ -136,12 +156,12 @@ 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'); @@ -149,14 +169,19 @@ describe('incident tools', () => { 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); }); }); @@ -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(); @@ -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', () => { diff --git a/src/client.ts b/src/client.ts index d5b4775..c40aebc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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; @@ -15,13 +15,15 @@ export class RunframeClient { async request( method: string, path: string, - body?: Record + body?: Record, + options?: RequestOptions ): Promise { const url = `${this.baseUrl}${path}`; const headers: Record = { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': `runframe-mcp-server/${VERSION}`, + ...(options?.headers ?? {}), }; const controller = new AbortController(); @@ -73,12 +75,12 @@ export class RunframeClient { return this.request('GET', path); } - post(path: string, body: Record): Promise { - return this.request('POST', path, body); + post(path: string, body: Record, options?: RequestOptions): Promise { + return this.request('POST', path, body, options); } - patch(path: string, body: Record): Promise { - return this.request('PATCH', path, body); + patch(path: string, body: Record, options?: RequestOptions): Promise { + return this.request('PATCH', path, body, options); } } diff --git a/src/server.ts b/src/server.ts index adccb78..31f019d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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({ @@ -19,6 +20,7 @@ export function createServer(client: RunframeClient): McpServer { registerServiceTools(server, client); registerPostmortemTools(server, client); registerTeamTools(server, client); + registerUserTools(server, client); return server; } diff --git a/src/tools/incidents.ts b/src/tools/incidents.ts index c20bf4b..084270f 100644 --- a/src/tools/incidents.ts +++ b/src/tools/incidents.ts @@ -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'), }, @@ -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'); } @@ -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'); } }); diff --git a/src/tools/teams.ts b/src/tools/teams.ts index 8956d3f..1185c44 100644 --- a/src/tools/teams.ts +++ b/src/tools/teams.ts @@ -7,6 +7,7 @@ export function registerTeamTools(server: McpServer, client: RunframeClient) { server.registerTool('runframe_list_teams', { description: 'List all teams in your organization.', inputSchema: { + search: z.string().optional().describe('Search by team name'), limit: z.number().min(1).max(100).default(20), offset: z.number().min(0).default(0), }, @@ -16,6 +17,7 @@ export function registerTeamTools(server: McpServer, client: RunframeClient) { const query = new URLSearchParams(); if (params.limit != null) query.set('limit', String(params.limit)); if (params.offset != null) query.set('offset', String(params.offset)); + if (params.search) query.set('search', params.search); const data = await client.get(`/api/v1/teams?${query}`); return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; } catch (error) { return toolError(error, 'runframe_list_teams'); } diff --git a/src/tools/users.ts b/src/tools/users.ts new file mode 100644 index 0000000..1d2cbfe --- /dev/null +++ b/src/tools/users.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RunframeClient } from '../client.js'; +import { toolError } from '../server.js'; + +export function registerUserTools(server: McpServer, client: RunframeClient) { + server.registerTool('runframe_find_user', { + description: 'Search active users by name or email so agents can resolve a person before filtering incidents by assignee or resolver.', + inputSchema: { + search: z.string().min(1).describe('Name or email search string'), + limit: z.number().min(1).max(25).default(10).describe('Maximum matches to return (default 10)'), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, async (params) => { + try { + const query = new URLSearchParams(); + query.set('search', params.search); + query.set('limit', String(params.limit ?? 10)); + query.set('offset', '0'); + query.set('is_active', 'true'); + const data = await client.get(`/api/v1/users?${query}`); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (error) { return toolError(error, 'runframe_find_user'); } + }); +} diff --git a/src/types.ts b/src/types.ts index 1e86664..412a14e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,11 +5,19 @@ export interface RunframeConfig { apiUrl: string; } +export interface RequestOptions { + headers?: Record; +} + export interface ApiErrorResponse { - success: false; error: { message: string; code: string; + userMessage?: string; details?: Record; }; + meta?: { + requestId?: string; + timestamp?: string; + }; }