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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Node 20+](https://img.shields.io/badge/node-≥20-brightgreen)](https://nodejs.org)

**[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.
**[Runframe](https://runframe.io)** is the complete incident lifecycle platform for engineering teams, covering incident response, on-call, and status pages. This MCP server lets you manage those workflows from your IDE or AI agent.

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

Expand Down Expand Up @@ -36,7 +36,7 @@ Ask your agent:
- *"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`
- *"Find Alex so I can check their open incidents"* → calls `runframe_find_user`

## Install

Expand Down Expand Up @@ -147,7 +147,7 @@ The server stores nothing. It is a pass-through to the Runframe API.

| Tool | Scopes | Description |
|------|--------|-------------|
| `runframe_find_user` | `users:read` | Search active users by name or email |
| `runframe_find_user` | `users:read` | Search users by name or email, with optional inactive-user support for historical lookups |

## Direct API alignment

Expand All @@ -158,6 +158,7 @@ This MCP server follows the public Runframe direct API contract.
- `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`.
- Set `include_inactive=true` on `runframe_find_user` when you need to resolve former employees in historical incident queries.
- Use `runframe_list_teams` with `search` to resolve a team name before filtering incidents by `team_id`.

## Docker
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "@runframe/mcp-server",
"version": "0.1.7",
"version": "0.1.8",
"description": "MCP server for Runframe incident management — any agent, any IDE, one system of record",
"license": "MIT",
"author": "Runframe (https://runframe.io)",
"type": "module",
"bin": {
"runframe-mcp-server": "./dist/index.js"
"runframe-mcp-server": "dist/index.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -28,7 +28,7 @@
"homepage": "https://runframe.io",
"repository": {
"type": "git",
"url": "https://github.com/runframe/runframe-mcp-server.git"
"url": "git+https://github.com/runframe/runframe-mcp-server.git"
},
"bugs": {
"url": "https://github.com/runframe/runframe-mcp-server/issues"
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/http-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,20 @@ describe('HTTP multi-token rotation', async () => {
assert.strictEqual(res.status, 401);
});
});

describe('HTTP startup errors', () => {
it('rejects cleanly when the port is already in use', async () => {
const client = new RunframeClient({ apiKey: 'rf_test', apiUrl: 'https://example.com' });
const port = 10000 + Math.floor(Math.random() * 50000);
const first = await startHttp(() => createMcpServer(client), port, TEST_HOST, TEST_TOKEN);

try {
await assert.rejects(
() => startHttp(() => createMcpServer(client), port, TEST_HOST, TEST_TOKEN),
(error: NodeJS.ErrnoException) => error.code === 'EADDRINUSE'
);
} finally {
first.close();
}
});
});
14 changes: 12 additions & 2 deletions src/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,15 +484,25 @@ describe('user tools', () => {

describe('runframe_find_user', () => {
it('GETs active 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'));
});

it('can include inactive users for historical lookups', async () => {
await callTool(mcpClient, 'runframe_find_user', {
search: 'alex',
include_inactive: true,
});
const call = mock.lastCall();
assert.ok(call.path.includes('search=alex'));
assert.ok(!call.path.includes('is_active=true'));
});
});
});

Expand Down
3 changes: 2 additions & 1 deletion src/tools/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function registerUserTools(server: McpServer, client: RunframeClient) {
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'),
},
annotations: { readOnlyHint: true, openWorldHint: true },
}, async (params) => {
Expand All @@ -17,7 +18,7 @@ export function registerUserTools(server: McpServer, client: RunframeClient) {
query.set('search', params.search);
query.set('limit', String(params.limit ?? 10));
query.set('offset', '0');
query.set('is_active', 'true');
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
16 changes: 13 additions & 3 deletions src/transports/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,20 @@ export async function startHttp(
}
);

return new Promise<Server>((resolve) => {
httpServer.listen(port, host, () => {
return new Promise<Server>((resolve, reject) => {
const onError = (error: Error) => {
httpServer.off('listening', onListening);
reject(error);
};

const onListening = () => {
httpServer.off('error', onError);
console.error(`[runframe-mcp] HTTP server listening on ${host}:${port}`);
resolve(httpServer);
});
};

httpServer.once('error', onError);
httpServer.once('listening', onListening);
httpServer.listen(port, host);
});
}
6 changes: 5 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export const VERSION = '0.1.7';
import { readFileSync } from 'node:fs';

export const VERSION = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
).version as string;

export interface RunframeConfig {
apiKey: string;
Expand Down
Loading