diff --git a/README.md b/README.md index c1c9bd1..a4ab9e8 100644 --- a/README.md +++ b/README.md @@ -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+. @@ -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 @@ -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 @@ -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 diff --git a/package-lock.json b/package-lock.json index a9d1dce..3ef308c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runframe/mcp-server", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runframe/mcp-server", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", diff --git a/package.json b/package.json index 16efa52..fea1290 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" diff --git a/src/__tests__/http-security.test.ts b/src/__tests__/http-security.test.ts index 05bca06..54af6ea 100644 --- a/src/__tests__/http-security.test.ts +++ b/src/__tests__/http-security.test.ts @@ -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(); + } + }); +}); diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 7541c81..ff4f68d 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -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')); + }); }); }); diff --git a/src/tools/users.ts b/src/tools/users.ts index 1d2cbfe..d810f5a 100644 --- a/src/tools/users.ts +++ b/src/tools/users.ts @@ -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) => { @@ -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'); } diff --git a/src/transports/http.ts b/src/transports/http.ts index 28e63de..f3f0e69 100644 --- a/src/transports/http.ts +++ b/src/transports/http.ts @@ -133,10 +133,20 @@ export async function startHttp( } ); - return new Promise((resolve) => { - httpServer.listen(port, host, () => { + return new Promise((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); }); } diff --git a/src/types.ts b/src/types.ts index c5a596a..5ea935e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;