From 4b09f5a04c7f275622fdbe122dde6d6cafa88e96 Mon Sep 17 00:00:00 2001 From: Niketa Sharma Date: Sun, 19 Apr 2026 15:11:59 +0100 Subject: [PATCH 1/3] Align MCP with latest V1 API --- README.md | 4 ++++ package.json | 2 +- src/__tests__/tools.test.ts | 40 +++++++++++++++++++++++++++++++++--- src/tools/incidents.ts | 41 ++++++++++++++++++++++++++++--------- src/tools/oncall.ts | 2 +- src/tools/users.ts | 10 +++++---- 6 files changed, 80 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a4ab9e8..3b5f0a8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index fea1290..c9ba92d 100644 --- a/package.json +++ b/package.json @@ -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)", diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index ff4f68d..f762614 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -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', () => { @@ -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', () => { @@ -483,12 +508,12 @@ describe('user tools', () => { }); describe('runframe_find_user', () => { - it('GETs active users with search and pagination defaults', async () => { - await callTool(mcpClient, 'runframe_find_user', { search: 'alex' }); + it('GETs 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=alex')); + 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')); @@ -498,10 +523,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: 'niketa', is_active: false, limit: 100 }); + const call = mock.lastCall(); + assert.ok(call.path.includes('is_active=false')); + assert.ok(call.path.includes('limit=100')); }); }); }); diff --git a/src/tools/incidents.ts b/src/tools/incidents.ts index 084270f..2617f3e 100644 --- a/src/tools/incidents.ts +++ b/src/tools/incidents.ts @@ -6,6 +6,25 @@ 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 ──────────────────────────────────────────────────────────── @@ -13,7 +32,7 @@ export function registerIncidentTools(server: McpServer, client: RunframeClient) 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'), @@ -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, }); @@ -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'); } diff --git a/src/tools/oncall.ts b/src/tools/oncall.ts index a5fab9c..c732cb1 100644 --- a/src/tools/oncall.ts +++ b/src/tools/oncall.ts @@ -5,7 +5,7 @@ import { toolError } from '../server.js'; 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 payload from the V1 API. If provided, `team_id` scopes the response to services covered by that team.', inputSchema: { team_id: z.string().uuid().optional().describe('Filter by team. If omitted, returns on-call for all teams.'), }, diff --git a/src/tools/users.ts b/src/tools/users.ts index d810f5a..20d21a9 100644 --- a/src/tools/users.ts +++ b/src/tools/users.ts @@ -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) => { @@ -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'); } From 0eca2e0a6236878fe25c141dee7a1eff3477c9ed Mon Sep 17 00:00:00 2001 From: Niketa Sharma Date: Sun, 19 Apr 2026 17:26:22 +0100 Subject: [PATCH 2/3] Normalize MCP on-call responses --- src/__tests__/tools.test.ts | 107 ++++++++++++++++++++++++++++++++++++ src/tools/oncall.ts | 70 ++++++++++++++++++++++- 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index f762614..f628f3f 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -339,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: 'Niketa Shah', + email: 'niketa@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: 'Niketa Shah', + 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: 'Niketa Shah', + email: 'niketa@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: 'Niketa Shah', + 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); + }); }); }); diff --git a/src/tools/oncall.ts b/src/tools/oncall.ts index c732cb1..2412fec 100644 --- a/src/tools/oncall.ts +++ b/src/tools/oncall.ts @@ -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; + 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).totalServices ?? (summary as Record).total_services ?? 0, + servicesWithCoverage: (summary as Record).servicesWithCoverage ?? (summary as Record).services_with_coverage ?? 0, + servicesWithoutCoverage: (summary as Record).servicesWithoutCoverage ?? (summary as Record).services_without_coverage ?? 0, + coveragePercentage: (summary as Record).coveragePercentage ?? (summary as Record).coverage_percentage ?? 0, + } + : null, + services: services.map((service) => { + if (!service || typeof service !== 'object') return service; + const serviceValue = service as Record; + 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; + 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).id ?? null, + name: (primary as Record).name ?? null, + role: (primary as Record).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 the current on-call payload from the V1 API. If provided, `team_id` scopes the response to services covered by that team.', + 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.'), }, @@ -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'); } }); } From 247f6c45b2d2d689e18662b13de0578a5d88c144 Mon Sep 17 00:00:00 2001 From: Niketa Sharma Date: Sun, 19 Apr 2026 17:29:17 +0100 Subject: [PATCH 3/3] Add on-call shape compatibility tests --- src/__tests__/tools.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index f628f3f..a33cb5b 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -359,8 +359,8 @@ describe('oncall tools', () => { on_call_engineers: [{ shift_id: 'shift-1', id: 'user-2', - name: 'Niketa Shah', - email: 'niketa@runframe.io', + name: 'Alex Morgan', + email: 'alex@runframe.io', slack_user_id: 'U123456', role: 'primary', schedule_id: 'schedule-1', @@ -371,7 +371,7 @@ describe('oncall tools', () => { has_coverage: true, primary_on_call: { id: 'user-2', - name: 'Niketa Shah', + name: 'Alex Morgan', role: 'primary', }, schedules: ['schedule-1'], @@ -397,8 +397,8 @@ describe('oncall tools', () => { onCallEngineers: [{ shiftId: 'shift-1', id: 'user-2', - name: 'Niketa Shah', - email: 'niketa@runframe.io', + name: 'Alex Morgan', + email: 'alex@runframe.io', slackUserId: 'U123456', role: 'primary', scheduleId: 'schedule-1', @@ -409,7 +409,7 @@ describe('oncall tools', () => { hasCoverage: true, primaryOnCall: { id: 'user-2', - name: 'Niketa Shah', + name: 'Alex Morgan', role: 'primary', }, schedules: ['schedule-1'], @@ -616,11 +616,11 @@ describe('user tools', () => { describe('runframe_find_user', () => { it('GETs users with search and pagination defaults', async () => { - await callTool(mcpClient, 'runframe_find_user', { search: 'niketa' }); + await callTool(mcpClient, 'runframe_find_user', { search: 'alex' }); 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('search=alex')); assert.ok(call.path.includes('is_active=true')); assert.ok(call.path.includes('limit=10')); assert.ok(call.path.includes('offset=0')); @@ -639,7 +639,7 @@ describe('user tools', () => { }); it('passes is_active when provided', async () => { - await callTool(mcpClient, 'runframe_find_user', { search: 'niketa', is_active: false, limit: 100 }); + 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'));