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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,13 @@ 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.
- `runframe_create_incident` defaults `severity` to `SEV2` when omitted, matching the V1 API.
- Incident create now mirrors the V1 API limits: `title` must be 1-200 chars, `description` maxes at 10000 chars, and `service_ids` allows at most 50 items.
- Incident creation depends on valid SLA configuration for the requested severity. If acknowledge or closure deadlines are missing, the API rejects the create.
- 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`.
- Set `include_inactive=true` on `runframe_find_user` when you need to resolve former employees in historical incident queries.
- Set `is_active=true` or `is_active=false` on `runframe_find_user` when you need an explicit V1 active-state filter.
- Use `runframe_list_teams` with `search` to resolve a team name before filtering incidents by `team_id`.

## Docker
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@runframe/mcp-server",
"version": "0.1.8",
"version": "0.1.9",
"description": "MCP server for Runframe incident management — any agent, any IDE, one system of record",
"license": "MIT",
"author": "Runframe (https://runframe.io)",
Expand Down
143 changes: 142 additions & 1 deletion src/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,19 @@ describe('incident tools', () => {
assert.strictEqual(call.headers?.['Idempotency-Key'], 'incident-create-001');
assert.strictEqual(call.body?.idempotency_key, undefined);
});

it('rejects more than 50 service_ids before sending request', async () => {
mock.reset({ id: 'new-id' });
const result = await callTool(mcpClient, 'runframe_create_incident', {
title: 'Redis Cache Storm',
service_ids: Array.from({ length: 51 }, (_, i) => `SER-${String(i + 1).padStart(5, '0')}`),
});

assert.strictEqual(result.isError, true);
assert.strictEqual(mock.calls.length, 0);
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
assert.ok(text.includes('50'), text);
});
});

describe('runframe_update_incident', () => {
Expand All @@ -200,6 +213,18 @@ describe('incident tools', () => {
assert.strictEqual(call.body?.severity, 'SEV0');
assert.strictEqual(call.body?.id, undefined, 'id should not be in body');
});

it('rejects empty update payload before sending request', async () => {
mock.reset({ id: '123' });
const result = await callTool(mcpClient, 'runframe_update_incident', {
id: 'INC-2026-001',
});

assert.strictEqual(result.isError, true);
assert.strictEqual(mock.calls.length, 0);
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
assert.ok(text.includes('At least one field must be provided for update'), text);
});
});

describe('runframe_change_incident_status', () => {
Expand Down Expand Up @@ -314,6 +339,113 @@ describe('oncall tools', () => {
const call = mock.lastCall();
assert.ok(call.path.includes(`team_id=${teamId}`));
});

it('normalizes snake_case on-call responses to stable camelCase output', async () => {
mock.reset({
timestamp: '2026-04-19T12:30:00.000Z',
summary: {
total_services: 3,
services_with_coverage: 2,
services_without_coverage: 1,
coverage_percentage: 67,
},
services: [{
service_id: 'service-1',
service_name: 'Payments API',
service_description: null,
team_id: 'team-1',
team_name: 'Platform',
team_description: null,
on_call_engineers: [{
shift_id: 'shift-1',
id: 'user-2',
name: 'Alex Morgan',
email: 'alex@runframe.io',
slack_user_id: 'U123456',
role: 'primary',
schedule_id: 'schedule-1',
schedule_name: 'Platform Primary',
shift_starts_at: '2026-04-19T09:00:00.000Z',
shift_ends_at: '2026-04-19T17:00:00.000Z',
}],
has_coverage: true,
primary_on_call: {
id: 'user-2',
name: 'Alex Morgan',
role: 'primary',
},
schedules: ['schedule-1'],
}],
});

const result = await callTool(mcpClient, 'runframe_get_current_oncall', {});
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
const parsed = JSON.parse(text);
assert.deepStrictEqual(parsed.summary, {
totalServices: 3,
servicesWithCoverage: 2,
servicesWithoutCoverage: 1,
coveragePercentage: 67,
});
assert.deepStrictEqual(parsed.services[0], {
serviceId: 'service-1',
serviceName: 'Payments API',
serviceDescription: null,
teamId: 'team-1',
teamName: 'Platform',
teamDescription: null,
onCallEngineers: [{
shiftId: 'shift-1',
id: 'user-2',
name: 'Alex Morgan',
email: 'alex@runframe.io',
slackUserId: 'U123456',
role: 'primary',
scheduleId: 'schedule-1',
scheduleName: 'Platform Primary',
shiftStartsAt: '2026-04-19T09:00:00.000Z',
shiftEndsAt: '2026-04-19T17:00:00.000Z',
}],
hasCoverage: true,
primaryOnCall: {
id: 'user-2',
name: 'Alex Morgan',
role: 'primary',
},
schedules: ['schedule-1'],
});
});

it('preserves legacy camelCase on-call responses', async () => {
mock.reset({
timestamp: '2026-04-19T12:30:00.000Z',
summary: {
totalServices: 1,
servicesWithCoverage: 1,
servicesWithoutCoverage: 0,
coveragePercentage: 100,
},
services: [{
serviceId: 'service-1',
serviceName: 'Payments API',
serviceDescription: null,
teamId: 'team-1',
teamName: 'Platform',
teamDescription: null,
onCallEngineers: [],
hasCoverage: true,
primaryOnCall: null,
schedules: [],
}],
});

const result = await callTool(mcpClient, 'runframe_get_current_oncall', {});
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
const parsed = JSON.parse(text);
assert.strictEqual(parsed.summary.totalServices, 1);
assert.strictEqual(parsed.services[0].serviceId, 'service-1');
assert.strictEqual(parsed.services[0].hasCoverage, true);
});
});
});

Expand Down Expand Up @@ -483,7 +615,7 @@ describe('user tools', () => {
});

describe('runframe_find_user', () => {
it('GETs active users with search and pagination defaults', async () => {
it('GETs users with search and pagination defaults', async () => {
await callTool(mcpClient, 'runframe_find_user', { search: 'alex' });
const call = mock.lastCall();
assert.strictEqual(call.method, 'GET');
Expand All @@ -498,10 +630,19 @@ describe('user tools', () => {
await callTool(mcpClient, 'runframe_find_user', {
search: 'alex',
include_inactive: true,
limit: 100,
});
const call = mock.lastCall();
assert.ok(call.path.includes('search=alex'));
assert.ok(!call.path.includes('is_active=true'));
assert.ok(call.path.includes('limit=100'));
});

it('passes is_active when provided', async () => {
await callTool(mcpClient, 'runframe_find_user', { search: 'alex', is_active: false, limit: 100 });
const call = mock.lastCall();
assert.ok(call.path.includes('is_active=false'));
assert.ok(call.path.includes('limit=100'));
});
});
});
Expand Down
41 changes: 31 additions & 10 deletions src/tools/incidents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,33 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { RunframeClient } from '../client.js';
import { toolError } from '../server.js';

const SeveritySchema = z.enum(['SEV0', 'SEV1', 'SEV2', 'SEV3', 'SEV4']);

const CreateIncidentBodySchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(10000).optional(),
severity: SeveritySchema.optional(),
service_ids: z.array(z.string().min(1)).min(1).max(50),
}).strict();

const UpdateIncidentBodySchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(10000).optional(),
severity: SeveritySchema.optional(),
assigned_to: z.string().uuid().optional(),
}).refine(
(data) => Object.values(data).some((value) => value !== undefined),
{ message: 'At least one field must be provided for update' }
);

export function registerIncidentTools(server: McpServer, client: RunframeClient) {

// ── list ────────────────────────────────────────────────────────────
server.registerTool('runframe_list_incidents', {
description: 'List incidents filtered by status, severity, or team. Returns paginated results.',
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'),
severity: z.array(SeveritySchema).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'),
Expand Down Expand Up @@ -62,16 +81,17 @@ export function registerIncidentTools(server: McpServer, client: RunframeClient)
server.registerTool('runframe_create_incident', {
description: 'Create a new incident from an alert or detection. Assignment happens automatically based on on-call schedules.',
inputSchema: {
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()).min(1).describe('Affected public service keys (for example SER-00001). Discover keys via runframe_list_services.'),
title: z.string().min(1).max(200).describe('Incident title (required, 1-200 chars)'),
description: z.string().max(10000).optional().describe('Detailed description (max 10000 chars)'),
severity: SeveritySchema.optional().describe('SEV0-SEV4, defaults to SEV2'),
service_ids: z.array(z.string().min(1)).min(1).max(50).describe('Affected public service keys (for example SER-00001). Discover keys via runframe_list_services. Max 50 items.'),
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 { idempotency_key, ...body } = params;
const { idempotency_key, ...rawBody } = params;
const body = CreateIncidentBodySchema.parse(rawBody);
const data = await client.post('/api/v1/incidents', body, {
headers: idempotency_key ? { 'Idempotency-Key': idempotency_key } : undefined,
});
Expand All @@ -84,15 +104,16 @@ export function registerIncidentTools(server: McpServer, client: RunframeClient)
description: 'Update incident fields: title, description, severity, or assignment. For status changes use runframe_change_incident_status instead.',
inputSchema: {
id: z.string().describe('Incident number (e.g. INC-2026-001) or UUID'),
title: z.string().max(200).optional(),
description: z.string().optional(),
severity: z.string().optional(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(10000).optional(),
severity: SeveritySchema.optional(),
assigned_to: z.string().uuid().optional(),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async (params) => {
try {
const { id, ...body } = params;
const { id, ...rawBody } = params;
const body = UpdateIncidentBodySchema.parse(rawBody);
const data = await client.patch(`/api/v1/incidents/${encodeURIComponent(id)}`, body);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error) { return toolError(error, 'runframe_update_incident'); }
Expand Down
70 changes: 68 additions & 2 deletions src/tools/oncall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,74 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { RunframeClient } from '../client.js';
import { toolError } from '../server.js';

function normalizeOncallData(data: unknown): unknown {
if (!data || typeof data !== 'object') return data;

const value = data as Record<string, unknown>;
const summary = value.summary;
const services = Array.isArray(value.services) ? value.services : [];

return {
...(value.organizationId ? { organizationId: value.organizationId } : {}),
timestamp: value.timestamp ?? null,
summary: summary && typeof summary === 'object'
? {
totalServices: (summary as Record<string, unknown>).totalServices ?? (summary as Record<string, unknown>).total_services ?? 0,
servicesWithCoverage: (summary as Record<string, unknown>).servicesWithCoverage ?? (summary as Record<string, unknown>).services_with_coverage ?? 0,
servicesWithoutCoverage: (summary as Record<string, unknown>).servicesWithoutCoverage ?? (summary as Record<string, unknown>).services_without_coverage ?? 0,
coveragePercentage: (summary as Record<string, unknown>).coveragePercentage ?? (summary as Record<string, unknown>).coverage_percentage ?? 0,
}
: null,
services: services.map((service) => {
if (!service || typeof service !== 'object') return service;
const serviceValue = service as Record<string, unknown>;
const engineers = Array.isArray(serviceValue.onCallEngineers)
? serviceValue.onCallEngineers
: Array.isArray(serviceValue.on_call_engineers)
? serviceValue.on_call_engineers
: [];
const primary = serviceValue.primaryOnCall ?? serviceValue.primary_on_call;

return {
serviceId: serviceValue.serviceId ?? serviceValue.service_id ?? null,
serviceName: serviceValue.serviceName ?? serviceValue.service_name ?? null,
serviceDescription: serviceValue.serviceDescription ?? serviceValue.service_description ?? null,
teamId: serviceValue.teamId ?? serviceValue.team_id ?? null,
teamName: serviceValue.teamName ?? serviceValue.team_name ?? null,
teamDescription: serviceValue.teamDescription ?? serviceValue.team_description ?? null,
onCallEngineers: engineers.map((engineer) => {
if (!engineer || typeof engineer !== 'object') return engineer;
const engineerValue = engineer as Record<string, unknown>;
return {
shiftId: engineerValue.shiftId ?? engineerValue.shift_id ?? null,
id: engineerValue.id ?? null,
name: engineerValue.name ?? null,
email: engineerValue.email ?? null,
slackUserId: engineerValue.slackUserId ?? engineerValue.slack_user_id ?? null,
role: engineerValue.role ?? null,
scheduleId: engineerValue.scheduleId ?? engineerValue.schedule_id ?? null,
scheduleName: engineerValue.scheduleName ?? engineerValue.schedule_name ?? null,
shiftStartsAt: engineerValue.shiftStartsAt ?? engineerValue.shift_starts_at ?? null,
shiftEndsAt: engineerValue.shiftEndsAt ?? engineerValue.shift_ends_at ?? null,
};
}),
hasCoverage: serviceValue.hasCoverage ?? serviceValue.has_coverage ?? false,
primaryOnCall: primary && typeof primary === 'object'
? {
id: (primary as Record<string, unknown>).id ?? null,
name: (primary as Record<string, unknown>).name ?? null,
role: (primary as Record<string, unknown>).role ?? null,
}
: null,
schedules: Array.isArray(serviceValue.schedules) ? serviceValue.schedules : [],
};
}),
};
}

export function registerOncallTools(server: McpServer, client: RunframeClient) {
server.registerTool('runframe_get_current_oncall', {
description: 'Get who is currently on call. Returns on-call engineers grouped by schedule, with their roles (primary, secondary, backup) and covered services.',
description: 'Get the current on-call coverage. Returns a stable camelCase MCP payload even if the upstream V1 API shape changes.',
inputSchema: {
team_id: z.string().uuid().optional().describe('Filter by team. If omitted, returns on-call for all teams.'),
},
Expand All @@ -15,7 +80,8 @@ export function registerOncallTools(server: McpServer, client: RunframeClient) {
const query = new URLSearchParams();
if (params.team_id) query.set('team_id', params.team_id);
const data = await client.get(`/api/v1/on-call/current?${query}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
const normalized = normalizeOncallData(data);
return { content: [{ type: 'text' as const, text: JSON.stringify(normalized, null, 2) }] };
} catch (error) { return toolError(error, 'runframe_get_current_oncall'); }
});
}
10 changes: 6 additions & 4 deletions src/tools/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ 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.',
description: 'Search 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)'),
include_inactive: z.boolean().default(false).describe('Include inactive users in the search results for historical assignee or resolver lookups'),
include_inactive: z.boolean().default(false).describe('Include inactive users in search results for historical assignee or resolver lookups'),
is_active: z.boolean().optional().describe('Optional active-state filter. Omit to search both active and inactive users.'),
limit: z.number().min(1).max(100).default(10).describe('Maximum matches to return (default 10, max 100)'),
},
annotations: { readOnlyHint: true, openWorldHint: true },
}, async (params) => {
Expand All @@ -18,7 +19,8 @@ export function registerUserTools(server: McpServer, client: RunframeClient) {
query.set('search', params.search);
query.set('limit', String(params.limit ?? 10));
query.set('offset', '0');
if (!params.include_inactive) query.set('is_active', 'true');
if (params.is_active !== undefined) query.set('is_active', String(params.is_active));
else if (!params.include_inactive) 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'); }
Expand Down
Loading