diff --git a/.gitignore b/.gitignore index a6b85b79..e0ca21c7 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ plan.md .claude/skills/** .agents/skills/** skills-lock.json + +.mcp.json diff --git a/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx b/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx index 32c89376..6abb2ba3 100644 --- a/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx +++ b/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils"; import type { RouterOutputs } from "@openstatus/api"; -import { SlackIcon } from "@openstatus/icons"; +import { ModelContextProtocolIcon, SlackIcon } from "@openstatus/icons"; import { Avatar, AvatarFallback, @@ -69,6 +69,16 @@ export const columns: ColumnDef[] = [ ); } + if (type === "mcp") { + return ( +
+
+ +
+ MCP Server +
+ ); + } if (type === "system") { return (
diff --git a/apps/dashboard/src/components/data-table/audit-logs-workspace/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/audit-logs-workspace/data-table-toolbar.tsx index 98755df7..165c56d0 100644 --- a/apps/dashboard/src/components/data-table/audit-logs-workspace/data-table-toolbar.tsx +++ b/apps/dashboard/src/components/data-table/audit-logs-workspace/data-table-toolbar.tsx @@ -14,6 +14,7 @@ const ACTOR_TYPE_LABELS: Record = { slack: "Slack", system: "System", subscriber: "Subscriber", + mcp: "MCP", }; function toOptions(values: Iterable, labels?: Record) { diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 9b0f0a1a..563f3f7f 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -186,10 +186,6 @@ export default defineConfig({ { label: "Reference", items: [ - { - label: "CLI Reference", - slug: "reference/cli-reference", - }, { label: "API Reference V1 - Deprecated", link: "https://api.openstatus.dev/v1", @@ -206,6 +202,14 @@ export default defineConfig({ target: "_blank", }, }, + { + label: "CLI Reference", + slug: "reference/cli-reference", + }, + { + label: "MCP Server", + slug: "reference/mcp-server", + }, { label: "DNS Monitor", slug: "reference/dns-monitor", diff --git a/apps/docs/src/content/docs/reference/mcp-server.mdx b/apps/docs/src/content/docs/reference/mcp-server.mdx new file mode 100644 index 00000000..912a4a95 --- /dev/null +++ b/apps/docs/src/content/docs/reference/mcp-server.mdx @@ -0,0 +1,128 @@ +--- +title: MCP Server +description: Connect openstatus to AI assistants via the Model Context Protocol +--- + +The openstatus MCP server lets AI assistants (Claude Desktop, Cursor, and other [Model Context Protocol](https://modelcontextprotocol.io) clients) read and manage your status pages, status reports, and maintenance windows directly from a conversation. + +## Endpoint + +``` +https://api.openstatus.dev/mcp +``` + +The transport is **Streamable HTTP** (stateless). The server speaks JSON-RPC 2.0 over a single endpoint that accepts `GET`, `POST`, and `DELETE`. + +## Authentication + +Every request must include your openstatus API key in the `x-openstatus-key` header — the same key REST and ConnectRPC use. There is no separate MCP-only credential. + +```http +x-openstatus-key: os_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Get a key from the dashboard's **Settings → API Tokens**. + +## Tools + +The server exposes 8 tools, scoped to the workspace tied to your API key. Mutations write to the audit log with `metadata.transport = "mcp"`. + +| Tool | Type | Purpose | +|----------------------------|----------|---------| +| `list_status_pages` | read | List public status pages with their slug and id. Used to discover the `pageId` required by mutation tools. | +| `list_status_reports` | read | List status reports newest-first. `filter: "active" \| "all"` (defaults to active = excludes resolved). Paginated via `page` (1-indexed) and `perPage`; response carries a `pagination` object with `page`, `perPage`, `totalSize`, and `totalPages`. | +| `list_maintenances` | read | List maintenance windows newest-first. Paginated via `page` (1-indexed) and `perPage`; response carries a `pagination` object with `page`, `perPage`, `totalSize`, and `totalPages`. | +| `create_status_report` | mutation | Create a new status report on a status page with an initial public update. | +| `add_status_report_update` | mutation | Append a public update to an existing status report and bump its status. | +| `update_status_report` | mutation | Edit a report's title, status, or affected components without posting a public update. | +| `resolve_status_report` | mutation | Mark a report resolved and post a final public update with the supplied message. | +| `create_maintenance` | mutation | Schedule a maintenance window (`from` / `to` are ISO 8601 strings). | + +The MCP client gates every tool call behind your approval — the server does not gate again. Each tool also carries [MCP annotations](https://modelcontextprotocol.io/specification/server/tools#tool-annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) so well-behaved clients can decide whether to confirm, cache, or surface differently. + +### Notifying subscribers + +Every mutation tool has a **required** `notify: boolean` field — there is no default. The tool's input schema rejects calls that omit it, which forces the LLM to make an explicit choice (and therefore ask the user) before firing. + +Notifications dispatch as part of the same call. There is no separate notify tool: if you create a status report or append an update with `notify: false`, that update will **never** reach subscribers — you cannot retroactively notify the same update later. This matches the dashboard and Slack agent semantics. + +The mutation and the notify dispatch are sequential, not transactional. The mutation persists first; if the notify step then throws (transient provider issue, partial outage of an integration), the response carries `notified: false` and the row stays. + +`notified: true` means the dispatch call returned without throwing — **not** that every subscriber received a message. If the workspace plan doesn't include subscriber notifications, the service is a silent no-op and the response will still report `notified: true`. Treat the field as "the dispatch ran cleanly," not as a delivery receipt. + +| Tool | What `notify: true` sends | +|----------------------------|---------------------------| +| `create_status_report` | Notification for the initial update | +| `add_status_report_update` | Notification for the new update | +| `resolve_status_report` | Resolution notification | +| `create_maintenance` | Maintenance scheduled notification | +| `update_status_report` | n/a — metadata-only edit, never has a notify path | + +The required `notify` field, combined with the mandatory draft-and-confirm workflow in each tool's description, encodes the contract that LLMs must: + +1. Draft the title/status/message/components. +2. Show the draft to the user. +3. Ask **explicitly** whether to notify subscribers. +4. Only call the tool once both content and notify are confirmed. + +The tool's response includes a `notified: boolean` field so the assistant can confirm what actually went out. If the workspace plan doesn't include subscriber notifications, `notify: true` is a no-op inside the service (no error, just nothing dispatched). + +### Lookup-before-mutate + +`create_status_report` and `create_maintenance` require a `pageId`. The tool descriptions instruct the model to call `list_status_pages` first; **never** type a numeric id you don't know — make the assistant resolve it. + +## Configure Claude Desktop + +Add the server to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows): + +```json +{ + "mcpServers": { + "openstatus": { + "url": "https://api.openstatus.dev/mcp", + "headers": { + "x-openstatus-key": "os_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + } +} +``` + +Restart Claude Desktop. The 8 openstatus tools appear in the tool picker. + +## Configure Cursor + +In Cursor, open **Settings → MCP → Add Server**: + +```json +{ + "openstatus": { + "url": "https://api.openstatus.dev/mcp", + "headers": { + "x-openstatus-key": "os_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } +} +``` + +## Errors + +Errors map by severity: + +- **Recoverable** (`NOT_FOUND`, `VALIDATION`, `CONFLICT`, `LIMIT_EXCEEDED`) come back as a tool result with `isError: true`. The model can read the message and retry with corrected input. +- **Transport-level** (`UNAUTHORIZED`, `FORBIDDEN`, `INTERNAL`, malformed input) come back as a JSON-RPC error. + +Error messages are not redacted — the consumer is an LLM that benefits from the detail. + +## Limits + +- `list_status_reports` and `list_maintenances` use offset pagination: `page` (1-indexed, default 1) and `perPage` (default 50, max 200). The response's `pagination.totalPages` tells the LLM whether more pages exist. `list_status_pages` is unpaginated (workspaces typically have a handful). +- No per-key rate limiting today. Treat MCP usage like REST: one server call per tool invocation. + +## Audit log + +Every mutation invoked through MCP appears in the workspace audit log with: + +- `actor_type = "mcp"` — slice by surface: `WHERE actor_type = 'mcp'`. +- `actor_id` = the API key's stable identifier (not the workspace id) — trace a specific key with `WHERE actor_id = ''`. +- `actor_user_id` = the openstatus user who created the API key (custom keys only) — attribute mutations back to a person with a join to the `user` table. diff --git a/apps/server/package.json b/apps/server/package.json index 58f4b4c2..a1d3c0de 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,7 +9,8 @@ "dev": "bun run --hot src/index.ts", "start": "NODE_ENV=production bun run src/index.ts", "test": "bun test", - "tsc": "tsc --noEmit" + "tsc": "tsc --noEmit", + "eval:mcp": "bun run src/routes/mcp/evals/run.ts" }, "dependencies": { "@bufbuild/protobuf": "2.10.2", @@ -17,9 +18,11 @@ "@connectrpc/connect": "2.1.1", "@connectrpc/connect-node": "2.1.1", "@connectrpc/validate": "^0.2.0", + "@hono/mcp": "0.2.5", "@hono/sentry": "1.2.2", "@hono/zod-openapi": "1.1.5", "@hono/zod-validator": "0.7.6", + "@modelcontextprotocol/sdk": "1.29.0", "@logtape/logtape": "2.0.1", "@logtape/otel": "2.0.1", "@logtape/sentry": "2.0.1", @@ -54,6 +57,7 @@ "@unkey/api": "2.2.0", "@upstash/qstash": "2.6.2", "hono": "4.11.3", + "hono-rate-limiter": "0.4.2", "nanoid": "5.0.7", "percentile": "1.6.0", "validator": "13.12.0", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6c4c66df..b4e1aa53 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -22,6 +22,7 @@ import openapiV1Json from "../static/openapi-v1.json" with { type: "json" }; import openapiYaml from "../static/openapi.yaml" with { type: "text" }; import { env } from "./env"; import { handleError } from "./libs/errors"; +import { mcpRoute } from "./routes/mcp"; import { publicRoute } from "./routes/public"; import { mountRpcRoutes } from "./routes/rpc"; import { slackRoute } from "./routes/slack"; @@ -245,6 +246,17 @@ app.route("/v1", api); */ app.route("/slack", slackRoute); +/** + * MCP Server Routes + * + * Streamable HTTP transport — single endpoint authenticated by + * `x-openstatus-key`. Per-request `McpServer` instance scoped to the + * caller's workspace via closure capture. Tools wrap + * `@openstatus/services` verbs; mutations write `metadata.transport: + * "mcp"` to the audit log via the shared `emitAudit` plumbing. + */ +app.route("/mcp", mcpRoute); + /** * TODO: move to `workflows` app * This route is used by our checker to update the status of the monitors, diff --git a/apps/server/src/libs/middlewares/auth.ts b/apps/server/src/libs/middlewares/auth.ts index 9b5e69bf..75656719 100644 --- a/apps/server/src/libs/middlewares/auth.ts +++ b/apps/server/src/libs/middlewares/auth.ts @@ -93,12 +93,49 @@ export async function authMiddleware( }; event.auth_method = result.authMethod; c.set("workspace", workspaceData); + // Always populate `apiKey` — falling back to a workspace-scoped + // placeholder for auth paths that didn't surface a stable key id + // (today: an Unkey response without `data.keyId`). Adapters can rely + // on the field being present without optional-chaining. Warn loudly + // when the fallback fires — audit attribution silently degrades to + // workspace-level, which is a regression we want to notice. + if (!result.keyId) { + logger.warn( + "authMiddleware: keyId missing, falling back to workspace placeholder {*}", + { + workspaceId: workspaceData.id, + authMethod: result.authMethod, + }, + ); + } + c.set("apiKey", { + id: result.keyId ?? `ws:${workspaceData.id}`, + createdById: result.createdById, + }); await next(); } export async function validateKey(key: string): Promise<{ - result: { valid: boolean; ownerId?: string; authMethod?: string }; + result: { + valid: boolean; + ownerId?: string; + authMethod?: string; + /** + * Stable identifier for the API key itself, not the workspace it + * belongs to. Audit logs read this to attribute mutations to the + * specific key. Custom keys: the `api_key.id` row id. Unkey: + * `data.keyId`. Dev: the input string. Super-admin: a sentinel. + */ + keyId?: string; + /** + * The openstatus user who created the API key — `api_key.created_by_id` + * for custom keys. Unkey/dev/super-admin don't expose a user + * mapping, so this is undefined for them; audit rows fall back to + * `actor_user_id = NULL`. + */ + createdById?: number; + }; error?: { message: string }; }> { if (env.NODE_ENV === "production") { @@ -143,6 +180,8 @@ export async function validateKey(key: string): Promise<{ valid: true, ownerId: String(customKey.workspaceId), authMethod: "custom_key", + keyId: String(customKey.id), + createdById: customKey.createdById, }, }; } @@ -162,6 +201,7 @@ export async function validateKey(key: string): Promise<{ valid: res.value.data.valid, ownerId: res.value.data.identity?.externalId, authMethod: "unkey", + keyId: res.value.data.keyId, }, error: undefined, }; @@ -169,7 +209,12 @@ export async function validateKey(key: string): Promise<{ // Special bypass for our workspace if (key.startsWith("sa_") && key === env.SUPER_ADMIN_TOKEN) { return { - result: { valid: true, ownerId: "1", authMethod: "super_admin" }, + result: { + valid: true, + ownerId: "1", + authMethod: "super_admin", + keyId: "super_admin", + }, }; } // In production, we only accept Unkey keys @@ -179,6 +224,10 @@ export async function validateKey(key: string): Promise<{ }); } - // In dev / test mode we can use the key as the ownerId - return { result: { valid: true, ownerId: key, authMethod: "dev" } }; + // In dev / test mode we can use the key as the ownerId. The same + // string also stands in for the keyId — there is no separate + // identity record to reference. + return { + result: { valid: true, ownerId: key, authMethod: "dev", keyId: key }, + }; } diff --git a/apps/server/src/routes/mcp/adapter.ts b/apps/server/src/routes/mcp/adapter.ts new file mode 100644 index 00000000..ff6c426c --- /dev/null +++ b/apps/server/src/routes/mcp/adapter.ts @@ -0,0 +1,113 @@ +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { Workspace } from "@openstatus/db/src/schema"; +import { type ServiceContext, ServiceError } from "@openstatus/services"; +import { ZodError } from "zod"; + +/** + * Build a `ServiceContext` for an MCP-originated request. + * + * `apiKey.id` is the real key identifier captured by the auth + * middleware — audit rows read it via `actor.keyId` so mutations + * attribute to the specific key, not the workspace. + * `apiKey.createdById` is the openstatus user who created the API key + * (custom keys only); propagated to `actor.userId` so + * `audit_log.actor_user_id` is populated. + * + * `actor.type` is `"mcp"` (not `"apiKey"`) so audit logs can be sliced + * by transport surface without leaning on side-channel metadata. + */ +export function toServiceCtx(args: { + workspace: Workspace; + apiKey: { id: string; createdById?: number }; + requestId?: string; +}): ServiceContext { + return { + workspace: args.workspace, + actor: { + type: "mcp", + keyId: args.apiKey.id, + userId: args.apiKey.createdById, + }, + requestId: args.requestId, + }; +} + +/** + * Convert any error from a tool's `execute` body into either a + * recoverable `CallToolResult` (so the LLM can retry with corrected + * input) or a thrown `McpError` (so the JSON-RPC envelope surfaces a + * transport-level failure). Mirrors the discrimination in + * `apps/server/src/routes/rpc/adapter.ts`. + */ +export function mapError(err: unknown): CallToolResult { + // ZodError surfaces from a service's `Input.parse(args.input)` — + // either a type mismatch the LLM passed or a refine() failure + // (e.g. `from > to` on `CreateMaintenanceInput`). Both are + // recoverable: the LLM can read the message and retry. Treated + // the same as `ServiceError.VALIDATION`. + if (err instanceof ZodError) { + return { + isError: true, + content: [{ type: "text", text: `Invalid input: ${err.message}` }], + }; + } + if (err instanceof ServiceError) { + switch (err.code) { + case "NOT_FOUND": + case "VALIDATION": + case "CONFLICT": + case "LIMIT_EXCEEDED": + case "PRECONDITION_FAILED": + // PRECONDITION_FAILED is recoverable in the LLM sense: the + // user can take a different action (cancel a subscription, + // free a quota slot) and try again. Surfacing it as + // `isError: true` lets the assistant relay the message to + // the user instead of retrying with mangled arguments, + // which is what `InvalidParams` would prompt. + return { + isError: true, + content: [{ type: "text", text: err.message }], + }; + case "UNAUTHORIZED": + case "FORBIDDEN": + case "INTERNAL": + throw new McpError(serviceErrorToMcpCode(err.code), err.message); + } + } + const message = err instanceof Error ? err.message : "Unknown error"; + throw new McpError(ErrorCode.InternalError, message); +} + +function serviceErrorToMcpCode( + code: "UNAUTHORIZED" | "FORBIDDEN" | "INTERNAL", +): ErrorCode { + switch (code) { + case "UNAUTHORIZED": + case "FORBIDDEN": + // JSON-RPC has no dedicated auth code; `InvalidRequest` is the + // closest standard fit and matches what other MCP servers + // return for permission failures. + return ErrorCode.InvalidRequest; + case "INTERNAL": + return ErrorCode.InternalError; + } +} + +/** + * Run a tool body through the error mapper. The unrecoverable branch + * throws synchronously inside `mapError`, so the outer `try` catches it + * and re-throws to the SDK; recoverable errors return a `CallToolResult` + * that the SDK forwards as the tool's response. + */ +export async function runTool( + fn: () => Promise, + toContent: (value: T) => CallToolResult, +): Promise { + try { + const value = await fn(); + return toContent(value); + } catch (err) { + return mapError(err); + } +} diff --git a/apps/server/src/routes/mcp/evals/cases.ts b/apps/server/src/routes/mcp/evals/cases.ts new file mode 100644 index 00000000..f2432a9d --- /dev/null +++ b/apps/server/src/routes/mcp/evals/cases.ts @@ -0,0 +1,111 @@ +/** + * Eval cases — natural-language prompts paired with the tool we expect + * the LLM to select. Used by `run.ts` to drive an LLM and assert the + * chosen tool name. + * + * Stays small on purpose. ~12 cases is enough to catch obvious + * description-language regressions; full coverage belongs in the unit + * tests, not here. + */ + +export type EvalCase = { + /** Short identifier shown in eval output. */ + id: string; + /** What the user types to the assistant. */ + prompt: string; + /** Tool the LLM is expected to call. */ + expectedTool: string; + /** If set, the chosen tool's `arguments` must include each listed key. */ + requiredArgs?: string[]; + /** + * If set, the chosen tool's `arguments` must NOT include any of these + * — used to guard against the "MUST call list_status_pages first" + * rule (the LLM should call the list tool *first*, not pass a guessed + * pageId straight into create_status_report). + */ + forbiddenArgs?: string[]; +}; + +export const cases: EvalCase[] = [ + // Tool-selection: list reads + { + id: "select.list_pages", + prompt: "Show me our public status pages.", + expectedTool: "list_status_pages", + }, + { + id: "select.list_active_reports", + prompt: "Are there any open incidents right now?", + expectedTool: "list_status_reports", + requiredArgs: ["filter"], + }, + { + id: "select.list_all_reports", + prompt: + "Give me every status report we've ever published, including resolved ones.", + expectedTool: "list_status_reports", + }, + { + id: "select.list_maintenances", + prompt: "What maintenance windows do we have scheduled?", + expectedTool: "list_maintenances", + }, + + // Prereq compliance: must call list_status_pages first when creating + { + id: "prereq.create_report_lists_pages_first", + prompt: + "Create a status report saying our payment gateway is down — investigating.", + expectedTool: "list_status_pages", + }, + { + id: "prereq.create_maintenance_lists_pages_first", + prompt: + "Schedule a maintenance window on our main status page tomorrow 14:00-15:00 UTC for database upgrade.", + expectedTool: "list_status_pages", + }, + + // Tool-selection: mutation verbs + { + id: "select.add_update", + prompt: + "Post a progress update to status report 42 saying we identified the root cause.", + expectedTool: "add_status_report_update", + requiredArgs: ["statusReportId", "status", "message"], + }, + { + id: "select.resolve", + prompt: "Mark status report 42 as resolved — the issue has been fixed.", + expectedTool: "resolve_status_report", + requiredArgs: ["statusReportId", "message"], + }, + { + id: "select.update_metadata", + prompt: 'Rename status report 42 to "Payments outage — May 1".', + expectedTool: "update_status_report", + requiredArgs: ["statusReportId", "title"], + }, + + // Param extraction + { + id: "params.add_update_status", + prompt: "Add an update to report 17: we're now monitoring after the fix.", + expectedTool: "add_status_report_update", + requiredArgs: ["statusReportId", "status", "message"], + }, + { + id: "params.resolve_message", + prompt: + "Resolve incident 99 and tell users that the rate limiter has been disabled.", + expectedTool: "resolve_status_report", + requiredArgs: ["statusReportId", "message"], + }, + + // Edge — disambiguation + { + id: "select.update_vs_add_update", + prompt: + 'Edit the title of status report 7 to "Investigating slow queries" — do not post a new public update.', + expectedTool: "update_status_report", + }, +]; diff --git a/apps/server/src/routes/mcp/evals/run.ts b/apps/server/src/routes/mcp/evals/run.ts new file mode 100644 index 00000000..f7296010 --- /dev/null +++ b/apps/server/src/routes/mcp/evals/run.ts @@ -0,0 +1,253 @@ +/** + * MCP tool-selection eval. Standalone bun script — `pnpm eval:mcp`. + * + * Runs each case in `cases.ts` against Claude Haiku 4.5 (via the AI + * Gateway), asserting the model picks the expected tool and includes + * the required args. Fails the run if fewer than `PASS_THRESHOLD` of + * `cases.length` succeed. + * + * Not in default CI. Cost: a handful of cents per run. + * + * -------------------------------------------------------------------- + * TODO: deduplicate tool catalogue. + * + * The `tools` map below MIRRORS the server registrations in: + * - apps/server/src/routes/mcp/tools/page.ts + * - apps/server/src/routes/mcp/tools/status-report.ts + * - apps/server/src/routes/mcp/tools/maintenance.ts + * + * When you edit a description, input shape, or required field in any + * of those files, edit it here too — drift is silent because evals + * are not in CI. + * + * The clean fix is to import each `register*Tools` factory, run them + * against a stub `McpServer` + stub ctx (handlers never execute), and + * convert each `RegisteredTool.description` + `inputSchema` into the + * AI SDK `tool()` shape. Attempted in an earlier revision; deferred + * because the SDK's Zod-shape conversion path needs more care than a + * few lines. + * -------------------------------------------------------------------- + */ + +import { gateway, generateText, stepCountIs, tool } from "ai"; +import { z } from "zod"; + +import { type EvalCase, cases } from "./cases"; + +// Resolved through the AI Gateway (`AI_GATEWAY_API_KEY` env). Using +// `gateway(...)` instead of a bare string makes the routing path +// explicit and gives a clearer error if the gateway is unconfigured. +const MODEL = gateway("anthropic/claude-haiku-4-5"); +// Lenient bar (10/12) accommodates model non-determinism even at +// `temperature: 0` — a single flaky tool selection shouldn't tank +// the run. Tighten if descriptions stabilize and runs trend toward +// 12/12. +const PASS_THRESHOLD = 10; + +const statusEnum = z.enum([ + "investigating", + "identified", + "monitoring", + "resolved", +]); + +const tools = { + list_status_pages: tool({ + description: + "List status pages in this workspace with their slug and ids. Use to discover the pageId required by create_status_report and create_maintenance.", + inputSchema: z.object({}), + execute: async () => ({ items: [] }), + }), + list_status_reports: tool({ + description: + "List status reports in this workspace, newest first. Filter by status (e.g. exclude 'resolved' to see active incidents). Returns the most recent update per report so the LLM can see the current public message without a follow-up call. Paginated via `page` (1-indexed) and `perPage`; response carries `pagination` with `totalSize` and `totalPages`.", + inputSchema: z.object({ + filter: z.enum(["active", "all"]).default("active"), + pageId: z.number().int().optional(), + page: z.number().int().min(1).default(1).optional(), + perPage: z.number().int().min(1).max(200).default(50).optional(), + }), + execute: async () => ({ + items: [], + pagination: { page: 1, perPage: 50, totalSize: 0, totalPages: 1 }, + }), + }), + list_maintenances: tool({ + description: + "List maintenance windows in this workspace, newest first. Paginated via `page` (1-indexed) and `perPage`; response carries `pagination` with `totalSize` and `totalPages`.", + inputSchema: z.object({ + pageId: z.number().int().optional(), + page: z.number().int().min(1).default(1).optional(), + perPage: z.number().int().min(1).max(200).default(50).optional(), + }), + execute: async () => ({ + items: [], + pagination: { page: 1, perPage: 50, totalSize: 0, totalPages: 1 }, + }), + }), + create_status_report: tool({ + description: + "Create a new status report on a public status page. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS — irreversible side effects. MANDATORY workflow before calling: 1) Draft the title/status/message/components. 2) Show the draft to the user. 3) Ask explicitly: 'Should I notify subscribers?'. 4) Call only after both content and notify are confirmed. Subscriber notifications dispatch as part of this call only — there is NO separate notify tool. pageId MUST come from list_status_pages.", + inputSchema: z.object({ + title: z.string(), + status: statusEnum, + message: z.string(), + pageId: z.number().int(), + pageComponentIds: z.array(z.number().int()).optional(), + date: z.string().optional(), + notify: z.boolean(), + }), + execute: async () => ({ ok: true }), + }), + add_status_report_update: tool({ + description: + "Append a new public update to an existing status report. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS. Use resolve_status_report instead if the new status would be 'resolved'. MANDATORY workflow: draft, show to user, ask about subscriber notification, then call. There is NO separate notify tool — if notify is false, this update never reaches subscribers.", + inputSchema: z.object({ + statusReportId: z.number().int(), + status: statusEnum, + message: z.string(), + date: z.string().optional(), + notify: z.boolean(), + }), + execute: async () => ({ ok: true }), + }), + update_status_report: tool({ + description: + "Edit metadata on an existing status report (title, status, affected components). Does NOT add a public update — use add_status_report_update for that. Does NOT and CANNOT notify subscribers — the schema rejects status: 'resolved' (use resolve_status_report). MANDATORY: draft, show, confirm before calling.", + inputSchema: z.object({ + statusReportId: z.number().int(), + title: z.string().optional(), + // Mirrors the production refine that rejects "resolved". + status: statusEnum + .refine((s) => s !== "resolved", { + error: + "update_status_report cannot set status to 'resolved' — use resolve_status_report instead.", + }) + .optional(), + pageComponentIds: z.array(z.number().int()).optional(), + }), + execute: async () => ({ ok: true }), + }), + resolve_status_report: tool({ + description: + "Resolve an active status report. Appends a final public update with the supplied message and flips status to 'resolved'. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS. MANDATORY: draft the resolution message, show to user, ask about subscriber notification, then call.", + inputSchema: z.object({ + statusReportId: z.number().int(), + message: z.string(), + date: z.string().optional(), + notify: z.boolean(), + }), + execute: async () => ({ ok: true }), + }), + create_maintenance: tool({ + description: + "Schedule a maintenance window on a status page. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS. pageId MUST come from list_status_pages. Times are ISO 8601. MANDATORY: draft title/message/window, show to user, ask about subscriber notification, then call.", + inputSchema: z.object({ + title: z.string(), + message: z.string(), + from: z.string(), + to: z.string(), + pageId: z.number().int(), + pageComponentIds: z.array(z.number().int()).optional(), + notify: z.boolean(), + }), + execute: async () => ({ ok: true }), + }), +}; + +const SYSTEM_PROMPT = `You are the openstatus assistant. Pick the right tool for each user request and call it with sensible parameters. NEVER guess a pageId — call list_status_pages first when you don't know it.`; + +type CaseResult = { + case: EvalCase; + pass: boolean; + reason?: string; + chosenTool?: string; + args?: Record; +}; + +async function runCase(c: EvalCase): Promise { + try { + const result = await generateText({ + model: MODEL, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: c.prompt }], + tools, + temperature: 0, + stopWhen: stepCountIs(1), + }); + + const firstCall = result.steps[0]?.toolCalls[0]; + if (!firstCall) { + return { + case: c, + pass: false, + reason: `no tool call (model said: ${result.text.slice(0, 80)})`, + }; + } + + if (firstCall.toolName !== c.expectedTool) { + return { + case: c, + pass: false, + reason: `expected ${c.expectedTool}, got ${firstCall.toolName}`, + chosenTool: firstCall.toolName, + args: firstCall.input as Record, + }; + } + + const args = (firstCall.input ?? {}) as Record; + if (c.requiredArgs) { + for (const key of c.requiredArgs) { + if (!(key in args)) { + return { + case: c, + pass: false, + reason: `missing required arg: ${key}`, + chosenTool: firstCall.toolName, + args, + }; + } + } + } + if (c.forbiddenArgs) { + for (const key of c.forbiddenArgs) { + if (key in args) { + return { + case: c, + pass: false, + reason: `passed forbidden arg: ${key}`, + chosenTool: firstCall.toolName, + args, + }; + } + } + } + return { case: c, pass: true, chosenTool: firstCall.toolName, args }; + } catch (err) { + return { + case: c, + pass: false, + reason: `threw: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +const results: CaseResult[] = []; +for (const c of cases) { + process.stdout.write(` ${c.id} ... `); + const r = await runCase(c); + results.push(r); + process.stdout.write(`${r.pass ? "PASS" : "FAIL"}\n`); + if (!r.pass) { + console.error(` ${r.reason}`); + if (r.chosenTool) console.error(` chose: ${r.chosenTool}`); + if (r.args) console.error(` args: ${JSON.stringify(r.args)}`); + } +} + +const passed = results.filter((r) => r.pass).length; +console.log(`\n${passed}/${cases.length} passed (threshold ${PASS_THRESHOLD})`); + +if (passed < PASS_THRESHOLD) { + process.exit(1); +} diff --git a/apps/server/src/routes/mcp/handler.test.ts b/apps/server/src/routes/mcp/handler.test.ts new file mode 100644 index 00000000..d1890bf8 --- /dev/null +++ b/apps/server/src/routes/mcp/handler.test.ts @@ -0,0 +1,293 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { sentry } from "@hono/sentry"; +import { Hono } from "hono"; +import { requestId } from "hono/request-id"; + +import { db, eq } from "@openstatus/db"; +import { auditLog, page, statusReport } from "@openstatus/db/src/schema"; +import { SEEDED_WORKSPACE_TEAM_ID } from "@openstatus/services/test/fixtures"; + +import { handleError } from "@/libs/errors"; +import { mcpRoute } from "./index"; + +/** + * Build a fresh Hono app with the same middleware chain as production. + * `@hono/sentry` is mounted even without a DSN — `handleError` reads + * `c.get("sentry")` on client errors (status < 499) and would silently + * fail without it, masking 401s as 200s. + */ +function makeApp() { + const app = new Hono<{ Variables: { event: Record } }>(); + app.use("*", sentry({ dsn: undefined })); + app.use("*", requestId()); + app.use("*", async (c, next) => { + // The auth middleware consults `c.get("event")` to record auth_method. + c.set("event", {}); + await next(); + }); + app.onError(handleError); + app.route("/mcp", mcpRoute); + return app; +} + +const KEY = String(SEEDED_WORKSPACE_TEAM_ID); + +/** + * Build a JSON-RPC POST request. Pass `key: false` to omit the + * `x-openstatus-key` header entirely (testing the unauthenticated + * path) — `undefined` would trigger the parameter default and + * silently authenticate, masking the missing-auth case. + */ +function jsonRpc(body: object, key: string | false = KEY): Request { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (key !== false) headers["x-openstatus-key"] = key; + return new Request("http://localhost/mcp", { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, ...body }), + }); +} + +async function readJsonRpc( + res: Response, +): Promise<{ result?: unknown; error?: { code: number; message: string } }> { + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("text/event-stream")) { + const text = await res.text(); + // SSE frames look like `event: message\ndata: {...}\n\n`. Pull the + // first `data:` payload. + const match = text.match(/data:\s*({.*})/); + if (!match) throw new Error(`no data frame in SSE response: ${text}`); + return JSON.parse(match[1]); + } + return res.json() as Promise<{ + result?: unknown; + error?: { code: number; message: string }; + }>; +} + +describe("MCP transport", () => { + test("rejects requests missing x-openstatus-key with 401", async () => { + const app = makeApp(); + const res = await app.fetch(jsonRpc({ method: "tools/list" }, false)); + expect(res.status).toBe(401); + }); + + test("tools/list returns the 8 registered tools", async () => { + const app = makeApp(); + const res = await app.fetch(jsonRpc({ method: "tools/list" })); + expect(res.status).toBe(200); + const body = await readJsonRpc(res); + const tools = (body.result as { tools: { name: string }[] }).tools; + const names = tools.map((t) => t.name).sort(); + expect(names).toEqual([ + "add_status_report_update", + "create_maintenance", + "create_status_report", + "list_maintenances", + "list_status_pages", + "list_status_reports", + "resolve_status_report", + "update_status_report", + ]); + }); + + test("tools/call list_status_pages succeeds with valid auth", async () => { + const app = makeApp(); + const res = await app.fetch( + jsonRpc({ + method: "tools/call", + params: { name: "list_status_pages", arguments: {} }, + }), + ); + expect(res.status).toBe(200); + const body = await readJsonRpc(res); + expect(body.error).toBeUndefined(); + const result = body.result as { + structuredContent?: { items: unknown[] }; + isError?: boolean; + }; + expect(result.isError).toBeUndefined(); + expect(Array.isArray(result.structuredContent?.items)).toBe(true); + }); + + test("tools/call with unknown tool name surfaces an error", async () => { + const app = makeApp(); + const res = await app.fetch( + jsonRpc({ + method: "tools/call", + params: { name: "definitely_not_a_tool", arguments: {} }, + }), + ); + // The SDK can surface unknown-tool failures three ways: as a + // JSON-RPC error, an HTTP 4xx, or as a `tools/call` result with + // `isError: true`. Accept any — assert "not silently successful". + if (res.status === 200) { + const body = (await readJsonRpc(res)) as { + result?: { isError?: boolean }; + error?: { code: number; message: string }; + }; + const failed = body.error !== undefined || body.result?.isError === true; + expect(failed).toBe(true); + } else { + expect(res.status).toBeGreaterThanOrEqual(400); + } + }); + + test("update_status_report rejects status: 'resolved' via Zod refine", async () => { + const app = makeApp(); + // We don't need a real status report — input validation runs + // against the schema before the handler executes, so the SDK + // surfaces the refine error regardless of statusReportId. + const res = await app.fetch( + jsonRpc({ + method: "tools/call", + params: { + name: "update_status_report", + arguments: { statusReportId: 1, status: "resolved" }, + }, + }), + ); + expect(res.status).toBe(200); + const body = (await readJsonRpc(res)) as { + result?: { isError?: boolean; content?: { text: string }[] }; + error?: { code: number; message: string }; + }; + const failed = body.error !== undefined || body.result?.isError === true; + expect(failed).toBe(true); + const text = body.error?.message ?? body.result?.content?.[0]?.text ?? ""; + expect(text).toMatch(/resolve_status_report/); + }); + + test("tools/list advertises non-empty descriptions and schemas", async () => { + const app = makeApp(); + const res = await app.fetch(jsonRpc({ method: "tools/list" })); + const body = await readJsonRpc(res); + const tools = ( + body.result as { + tools: { + name: string; + description?: string; + inputSchema?: object; + outputSchema?: object; + }[]; + } + ).tools; + expect(tools).toHaveLength(8); + for (const tool of tools) { + expect(tool.description?.length ?? 0).toBeGreaterThan(40); + expect(tool.inputSchema).toBeDefined(); + expect(tool.outputSchema).toBeDefined(); + } + }); +}); + +/** + * End-to-end actor stamping — drive a real `tools/call` through + * `app.fetch` and verify the resulting audit row has + * `actorType: "mcp"` and a real `actorId` (the API key id captured by + * the auth middleware, not a workspace placeholder). Distinct from the + * unit tests, which assert this on direct handler calls — this proves + * the entire chain (auth → toServiceCtx → tool → service → emitAudit) + * end-to-end. + * + * Cannot use `withTestTransaction` here because the request goes + * through `app.fetch` which reaches the default db. Tracks created ids + * for `afterAll` cleanup. + */ +describe("MCP transport — audit stamping", () => { + const E2E_PREFIX = "mcp-handler-test"; + let e2ePageId: number | null = null; + const createdReports: number[] = []; + + beforeAll(async () => { + const row = await db + .insert(page) + .values({ + workspaceId: SEEDED_WORKSPACE_TEAM_ID, + title: `${E2E_PREFIX}-page`, + description: "e2e page", + slug: `${E2E_PREFIX}-page-slug`, + customDomain: "", + }) + .returning() + .get(); + e2ePageId = row.id; + }); + + afterAll(async () => { + for (const id of createdReports) { + await db + .delete(auditLog) + .where(eq(auditLog.entityId, String(id))) + .catch(() => undefined); + await db + .delete(statusReport) + .where(eq(statusReport.id, id)) + .catch(() => undefined); + } + if (e2ePageId !== null) { + await db + .delete(page) + .where(eq(page.id, e2ePageId)) + .catch(() => undefined); + } + }); + + test("tools/call create_status_report writes audit row with transport=mcp", async () => { + const app = makeApp(); + if (e2ePageId === null) throw new Error("setup failed"); + const res = await app.fetch( + jsonRpc({ + method: "tools/call", + params: { + name: "create_status_report", + arguments: { + title: `${E2E_PREFIX}-create`, + status: "investigating", + message: "e2e investigating", + pageId: e2ePageId, + pageComponentIds: [], + notify: false, + }, + }, + }), + ); + expect(res.status).toBe(200); + const body = await readJsonRpc(res); + expect(body.error).toBeUndefined(); + const result = body.result as { + isError?: boolean; + structuredContent?: { + statusReport: { id: number }; + }; + }; + expect(result.isError).toBeUndefined(); + const reportId = result.structuredContent?.statusReport.id; + expect(reportId).toBeGreaterThan(0); + if (typeof reportId === "number") createdReports.push(reportId); + + const rows = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, String(reportId))) + .all(); + const createRow = rows.find((r) => r.action === "status_report.create"); + expect(createRow).toBeDefined(); + expect(createRow?.actorType).toBe("mcp"); + // In dev mode `validateKey` short-circuits the custom-key DB + // lookup and treats the input string as both ownerId and keyId. + // Asserting actorId === KEY proves keyId propagates structurally + // through auth → adapter → service → audit, but does NOT exercise + // the production `api_key.id` path. A NODE_ENV=production test + // with a real api_key fixture would close that gap; out of scope + // here because flipping NODE_ENV reroutes other middleware. + expect(createRow?.actorId).toBe(KEY); + // No `createdById` available in dev mode → audit's actorUserId + // is null. Production custom keys would carry the creator's id. + expect(createRow?.actorUserId).toBeNull(); + }); +}); diff --git a/apps/server/src/routes/mcp/index.ts b/apps/server/src/routes/mcp/index.ts new file mode 100644 index 00000000..419f324c --- /dev/null +++ b/apps/server/src/routes/mcp/index.ts @@ -0,0 +1,88 @@ +import { StreamableHTTPTransport } from "@hono/mcp"; +import { Hono } from "hono"; + +import { handleError } from "@/libs/errors"; +import { authMiddleware } from "@/libs/middlewares/auth"; +import type { Variables } from "@/types"; + +import { toServiceCtx } from "./adapter"; +import { createMcpServer } from "./server"; + +export const mcpRoute = new Hono<{ Variables: Variables }>({ strict: false }); + +// Match production's global error handler at the sub-router level so +// `OpenStatusApiError` (thrown by `authMiddleware` on bad/missing +// keys) translates to the right HTTP status whether or not the parent +// app has its own `onError` wired up. This makes the route portable +// across mount points and self-contained for tests. +mcpRoute.onError(handleError); + +mcpRoute.use("*", authMiddleware); + +/** + * The transport handler MUST return a JSON-RPC error envelope on + * unexpected throws — Hono's default `app.onError(handleError)` returns + * the openstatus HTTP error shape, which MCP clients can't parse and + * will treat as a transport disconnect. Auth failures (thrown before + * we reach the transport) still flow to the global error handler and + * surface as HTTP 401, which is correct. + */ +mcpRoute.all("/", async (c) => { + const workspace = c.get("workspace"); + const requestId = c.get("requestId"); + const apiKey = c.get("apiKey"); + const ctx = toServiceCtx({ + workspace, + apiKey, + requestId, + }); + + // Pre-parse the JSON-RPC body so we can mirror the request `id` on + // any error envelope we synthesize below. Per JSON-RPC 2.0 the + // response `id` MUST equal the request `id` when known; `null` is + // only correct for un-parseable requests. Reading the body here also + // means we can pass it as `parsedBody` to the transport, avoiding a + // double-parse downstream. + let parsedBody: unknown; + let requestRpcId: string | number | null = null; + if (c.req.method === "POST") { + try { + parsedBody = await c.req.json(); + const idCandidate = (parsedBody as { id?: unknown } | null)?.id; + if (typeof idCandidate === "string" || typeof idCandidate === "number") { + requestRpcId = idCandidate; + } + } catch { + // Malformed body — leave `parsedBody` undefined; the transport + // will produce its own ParseError JSON-RPC envelope. + } + } + + // Stateless mode: a fresh `McpServer` + transport per request. Both + // are local to this scope and become garbage-collectable once the + // returned Response stream is consumed by Hono. We deliberately do + // NOT call `server.close()` in a `finally` — closing tears down the + // SSE stream before Hono finishes writing the body, sending an + // empty response to the client. + const server = createMcpServer(ctx); + const transport = new StreamableHTTPTransport(); + try { + await server.connect(transport); + return await transport.handleRequest(c, parsedBody); + } catch (err) { + // HTTP 200 + JSON-RPC error envelope is intentional. JSON-RPC 2.0 + // expresses application-level errors *inside* the response body + // (the `error` field), with the transport HTTP status reserved + // for transport-level failures. Auth failures throw earlier and + // surface as HTTP 401 via `mcpRoute.onError(handleError)`. + const message = err instanceof Error ? err.message : "Internal error"; + return c.json( + { + jsonrpc: "2.0", + id: requestRpcId, + error: { code: -32603, message }, + }, + { status: 200 }, + ); + } +}); diff --git a/apps/server/src/routes/mcp/server.ts b/apps/server/src/routes/mcp/server.ts new file mode 100644 index 00000000..25dccecb --- /dev/null +++ b/apps/server/src/routes/mcp/server.ts @@ -0,0 +1,28 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServiceContext } from "@openstatus/services"; + +import packageJson from "../../../package.json" with { type: "json" }; +import { registerMaintenanceTools } from "./tools/maintenance"; +import { registerPageTools } from "./tools/page"; +import { registerStatusReportTools } from "./tools/status-report"; + +/** + * Build a fresh `McpServer` for this request. Each tool registration + * closes over `ctx`, so every tool invocation reads the workspace + + * actor of the request that created the server — workspace scoping is + * enforced structurally rather than via a per-call lookup. The server + * and its transport are scoped to a single request and become + * garbage-collectable once the response stream is consumed. + * + * Static tool list (`listChanged: false`); no prompts or resources. + */ +export function createMcpServer(ctx: ServiceContext): McpServer { + const server = new McpServer( + { name: "openstatus", version: packageJson.version }, + { capabilities: { tools: { listChanged: false } } }, + ); + registerPageTools(server, ctx); + registerStatusReportTools(server, ctx); + registerMaintenanceTools(server, ctx); + return server; +} diff --git a/apps/server/src/routes/mcp/tools/maintenance.ts b/apps/server/src/routes/mcp/tools/maintenance.ts new file mode 100644 index 00000000..b3748b89 --- /dev/null +++ b/apps/server/src/routes/mcp/tools/maintenance.ts @@ -0,0 +1,235 @@ +import { getLogger } from "@logtape/logtape"; +import type { + McpServer, + RegisteredTool, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServiceContext } from "@openstatus/services"; +import { + createMaintenance, + listMaintenances, + notifyMaintenance, +} from "@openstatus/services/maintenance"; +import { z } from "zod"; + +import { runTool } from "../adapter"; + +const logger = getLogger("api-server"); + +const PER_PAGE_DEFAULT = 50; +const PER_PAGE_MAX = 200; + +export function registerMaintenanceTools( + server: McpServer, + ctx: ServiceContext, +): Map { + const registered = new Map(); + + registered.set( + "list_maintenances", + server.registerTool( + "list_maintenances", + { + description: + "List maintenance windows in this workspace, newest first. Paginated via `page` (1-indexed) and `perPage`. The response's `pagination` object carries `totalSize`, `totalPages`, `page`, and `perPage` so the LLM can decide whether to fetch the next page or warn the user that the result is paginated.", + annotations: { readOnlyHint: true, openWorldHint: false }, + inputSchema: { + pageId: z + .number() + .int() + .optional() + .describe("If set, only maintenances attached to this page id."), + page: z + .number() + .int() + .min(1) + .default(1) + .describe("1-indexed page number (default 1)."), + perPage: z + .number() + .int() + .min(1) + .max(PER_PAGE_MAX) + .default(PER_PAGE_DEFAULT) + .describe( + `Items per page (default ${PER_PAGE_DEFAULT}, max ${PER_PAGE_MAX}).`, + ), + }, + outputSchema: { + items: z.array( + z.object({ + id: z.number().int(), + title: z.string(), + message: z.string(), + from: z.string(), + to: z.string(), + pageId: z.number().int().nullable(), + pageComponentIds: z.array(z.number().int()), + }), + ), + pagination: z.object({ + page: z.number().int(), + perPage: z.number().int(), + totalSize: z.number().int(), + totalPages: z.number().int(), + }), + }, + }, + async ({ pageId, page, perPage }) => + runTool( + () => + listMaintenances({ + ctx, + input: { + limit: perPage ?? PER_PAGE_DEFAULT, + offset: ((page ?? 1) - 1) * (perPage ?? PER_PAGE_DEFAULT), + pageId, + order: "desc", + }, + }), + ({ items, totalSize }) => { + const currentPage = page ?? 1; + const size = perPage ?? PER_PAGE_DEFAULT; + const summarised = items.map((m) => ({ + id: m.id, + title: m.title, + message: m.message, + from: m.from.toISOString(), + to: m.to.toISOString(), + pageId: m.pageId, + pageComponentIds: m.pageComponentIds, + })); + const out = { + items: summarised, + pagination: { + page: currentPage, + perPage: size, + totalSize, + totalPages: Math.max(1, Math.ceil(totalSize / size)), + }, + }; + return { + content: [{ type: "text", text: JSON.stringify(out) }], + structuredContent: out, + }; + }, + ), + ), + ); + + registered.set( + "create_maintenance", + server.registerTool( + "create_maintenance", + { + description: + "Schedule a maintenance window on a status page. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS — irreversible side effects.\n\nMANDATORY workflow before calling:\n1. Draft the title, message, and time window (ISO 8601 from/to).\n2. Show the draft to the user for review.\n3. Ask explicitly: 'Should I notify subscribers (email + integrations) about this maintenance window? yes/no'.\n4. Only call this tool once the user has confirmed BOTH the content AND the notify decision.\n\nSubscriber notifications dispatch as part of this call only — you cannot notify retroactively for an existing window. There is NO separate notify tool. If notify is false here, this window will never reach subscribers. Note: the maintenance row persists even if the notify dispatch fails; the response's `notified` field reports whether subscribers were actually notified.\n\npageId MUST come from list_status_pages — never guess.", + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + inputSchema: { + title: z + .string() + .min(1) + .max(256) + .describe("Short, public-facing title for the maintenance window."), + message: z + .string() + .min(1) + .describe("Public message describing the work, shown on the page."), + from: z.iso + .datetime() + .describe("Start time, ISO 8601 (e.g. 2026-04-30T14:00:00Z)."), + to: z.iso + .datetime() + .describe( + "End time, ISO 8601. Must be strictly after `from` — service rejects otherwise.", + ), + pageId: z + .number() + .int() + .describe( + "Status page to attach the maintenance to. Resolve via list_status_pages — do not guess.", + ), + pageComponentIds: z + .array(z.number().int()) + .default([]) + .describe( + "Optional component ids affected by the maintenance. Must belong to pageId.", + ), + notify: z + .boolean() + .describe( + "REQUIRED. Whether to dispatch subscriber notifications for the new window. You MUST ask the user before deciding — do not infer. true = notify on creation. false = create silently; subscribers will NEVER be notified about this window (you cannot notify retroactively).", + ), + }, + outputSchema: { + id: z.number().int(), + title: z.string(), + from: z.string(), + to: z.string(), + pageId: z.number().int().nullable(), + notified: z.boolean(), + }, + }, + async (input) => + runTool( + async () => { + const record = await createMaintenance({ + ctx, + input: { + title: input.title, + message: input.message, + from: new Date(input.from), + to: new Date(input.to), + pageId: input.pageId, + pageComponentIds: input.pageComponentIds ?? [], + }, + }); + // The maintenance row is already persisted; a notify + // dispatch failure must not propagate as a tool error. + // Otherwise the LLM treats the whole call as failed and + // may retry, double-publishing the window. Report + // partial success via `notified: false`. + let notified = false; + if (input.notify) { + try { + await notifyMaintenance({ + ctx, + input: { maintenanceId: record.id }, + }); + notified = true; + } catch (err) { + logger.error( + "notifyMaintenance failed after create_maintenance {*}", + { + err, + workspaceId: ctx.workspace.id, + maintenanceId: record.id, + }, + ); + } + } + return { record, notified }; + }, + ({ record, notified }) => { + const out = { + id: record.id, + title: record.title, + from: record.from.toISOString(), + to: record.to.toISOString(), + pageId: record.pageId, + notified, + }; + return { + content: [{ type: "text", text: JSON.stringify(out) }], + structuredContent: out, + }; + }, + ), + ), + ); + + return registered; +} diff --git a/apps/server/src/routes/mcp/tools/page.ts b/apps/server/src/routes/mcp/tools/page.ts new file mode 100644 index 00000000..bcb3879e --- /dev/null +++ b/apps/server/src/routes/mcp/tools/page.ts @@ -0,0 +1,64 @@ +import type { + McpServer, + RegisteredTool, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServiceContext } from "@openstatus/services"; +import { listPages } from "@openstatus/services/page"; +import { z } from "zod"; + +import { runTool } from "../adapter"; + +// Pages carry secrets (`password`, `customDomain` access controls) that +// must never reach an LLM client. Output is a deliberately slim shape +// (id, title, slug only) rather than the full row. +// +// No `limit` input: pages are low-cardinality (workspaces have a +// handful), and `listPages` doesn't support `limit` push-down. A +// client-side slice would lie about the cap. Add real pagination if a +// workspace ever hits a problematic page count. + +export function registerPageTools( + server: McpServer, + ctx: ServiceContext, +): Map { + const registered = new Map(); + + registered.set( + "list_status_pages", + server.registerTool( + "list_status_pages", + { + description: + "List status pages in this workspace with their slug and ids. Use to discover the pageId required by create_status_report and create_maintenance.", + annotations: { readOnlyHint: true, openWorldHint: false }, + inputSchema: {}, + outputSchema: { + items: z.array( + z.object({ + id: z.number().int(), + title: z.string(), + slug: z.string(), + }), + ), + }, + }, + async () => + runTool( + () => listPages({ ctx, input: { order: "desc" } }), + (pages) => { + const items = pages.map((p) => ({ + id: p.id, + title: p.title, + slug: p.slug, + })); + return { + content: [{ type: "text", text: JSON.stringify({ items }) }], + structuredContent: { items }, + }; + }, + ), + ), + ); + + return registered; +} diff --git a/apps/server/src/routes/mcp/tools/status-report.ts b/apps/server/src/routes/mcp/tools/status-report.ts new file mode 100644 index 00000000..45858612 --- /dev/null +++ b/apps/server/src/routes/mcp/tools/status-report.ts @@ -0,0 +1,543 @@ +import { getLogger } from "@logtape/logtape"; +import type { + McpServer, + RegisteredTool, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import { statusReportStatusSchema } from "@openstatus/db/src/schema"; +import type { ServiceContext } from "@openstatus/services"; +import { + addStatusReportUpdate, + createStatusReport, + listStatusReports, + notifyStatusReport, + resolveStatusReport, + updateStatusReport, +} from "@openstatus/services/status-report"; +import { z } from "zod"; + +import { runTool } from "../adapter"; + +const logger = getLogger("api-server"); + +const PER_PAGE_DEFAULT = 50; +const PER_PAGE_MAX = 200; + +/** + * "Active" = every status except `resolved`. Computed from the schema + * so adding a new variant (e.g. `degraded`) automatically extends the + * filter without touching this file. + */ +const ACTIVE_STATUSES = statusReportStatusSchema.options.filter( + (s) => s !== "resolved", +); + +export function registerStatusReportTools( + server: McpServer, + ctx: ServiceContext, +): Map { + const registered = new Map(); + + registered.set( + "list_status_reports", + server.registerTool( + "list_status_reports", + { + description: + "List status reports in this workspace, newest first. Filter by status (e.g. exclude 'resolved' to see active incidents). Returns the most recent update per report so the LLM can see the current public message without a follow-up call. Paginated via `page` (1-indexed) and `perPage`. The response's `pagination` object carries `totalSize`, `totalPages`, `page`, and `perPage` so the LLM can decide whether to fetch the next page or warn the user.", + annotations: { readOnlyHint: true, openWorldHint: false }, + inputSchema: { + filter: z + .enum(["active", "all"]) + .default("active") + .describe( + "active = exclude resolved reports (default). all = every report regardless of status.", + ), + pageId: z + .number() + .int() + .optional() + .describe("If set, only reports attached to this page id."), + page: z + .number() + .int() + .min(1) + .default(1) + .describe("1-indexed page number (default 1)."), + perPage: z + .number() + .int() + .min(1) + .max(PER_PAGE_MAX) + .default(PER_PAGE_DEFAULT) + .describe( + `Items per page (default ${PER_PAGE_DEFAULT}, max ${PER_PAGE_MAX}).`, + ), + }, + outputSchema: { + items: z.array( + z.object({ + id: z.number().int(), + title: z.string(), + status: statusReportStatusSchema, + pageId: z.number().int().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + latestUpdate: z + .object({ + message: z.string(), + status: statusReportStatusSchema, + date: z.string().nullable(), + }) + .nullable(), + }), + ), + pagination: z.object({ + page: z.number().int(), + perPage: z.number().int(), + totalSize: z.number().int(), + totalPages: z.number().int(), + }), + }, + }, + async ({ filter, pageId, page, perPage }) => + runTool( + () => + listStatusReports({ + ctx, + input: { + limit: perPage ?? PER_PAGE_DEFAULT, + offset: ((page ?? 1) - 1) * (perPage ?? PER_PAGE_DEFAULT), + statuses: filter === "active" ? ACTIVE_STATUSES : [], + pageId, + order: "desc", + }, + }), + ({ items, totalSize }) => { + const currentPage = page ?? 1; + const size = perPage ?? PER_PAGE_DEFAULT; + const summarised = items.map((r) => { + const latestUpdate = r.updates[0] ?? null; + return { + id: r.id, + title: r.title, + status: r.status, + pageId: r.pageId, + createdAt: r.createdAt?.toISOString() ?? null, + updatedAt: r.updatedAt?.toISOString() ?? null, + latestUpdate: latestUpdate + ? { + message: latestUpdate.message, + status: latestUpdate.status, + date: latestUpdate.date?.toISOString() ?? null, + } + : null, + }; + }); + const out = { + items: summarised, + pagination: { + page: currentPage, + perPage: size, + totalSize, + totalPages: Math.max(1, Math.ceil(totalSize / size)), + }, + }; + return { + content: [ + { + type: "text", + text: JSON.stringify(out), + }, + ], + structuredContent: out, + }; + }, + ), + ), + ); + + registered.set( + "create_status_report", + server.registerTool( + "create_status_report", + { + description: + "Create a new status report on a public status page. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS — irreversible side effects.\n\nMANDATORY workflow before calling:\n1. Draft the title, status, message, and affected components.\n2. Show the draft to the user for review.\n3. Ask explicitly: 'Should I notify subscribers (email + integrations) for this report? yes/no'.\n4. Only call this tool once the user has confirmed BOTH the content AND the notify decision.\n\nSubscriber notifications dispatch as part of this call only — you cannot notify retroactively for an existing update. There is NO separate notify tool. If notify is false here, that update will never reach subscribers. Note: the report persists even if the notify dispatch fails; the response's `notified` field reports whether subscribers were actually notified.\n\npageId MUST come from list_status_pages — never guess. pageComponentIds (if supplied) MUST belong to the same page.", + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + inputSchema: { + title: z + .string() + .min(1) + .max(256) + .describe("Short, public-facing title for the incident."), + status: statusReportStatusSchema.describe( + "Initial status for the report.", + ), + message: z + .string() + .min(1) + .describe( + "Initial public update message customers will see on the status page.", + ), + pageId: z + .number() + .int() + .describe( + "Status page to attach the report to. Resolve via list_status_pages — do not guess.", + ), + pageComponentIds: z + .array(z.number().int()) + .default([]) + .describe( + "Optional component ids affected by the incident. Must belong to pageId.", + ), + date: z.iso + .datetime() + .optional() + .describe( + "Override the initial update's date. Defaults to now if omitted.", + ), + notify: z + .boolean() + .describe( + "REQUIRED. Whether to dispatch subscriber notifications (email + integrations) for the initial update. You MUST ask the user before deciding — do not infer. true = notify on creation. false = create silently; subscribers will NEVER be notified about this update (you cannot notify retroactively). No-op if the workspace plan has subscriptions disabled.", + ), + }, + outputSchema: { + statusReport: z.object({ + id: z.number().int(), + title: z.string(), + status: statusReportStatusSchema, + pageId: z.number().int().nullable(), + createdAt: z.string().nullable(), + }), + initialUpdateId: z.number().int(), + notified: z.boolean(), + }, + }, + async (input) => + runTool( + async () => { + const result = await createStatusReport({ + ctx, + input: { + title: input.title, + status: input.status, + message: input.message, + pageId: input.pageId, + pageComponentIds: input.pageComponentIds ?? [], + date: input.date ? new Date(input.date) : new Date(), + }, + }); + // Mutation succeeded; the row exists. A notify failure + // here must NOT propagate as a tool error — that would + // leave the LLM thinking the whole call failed and + // possibly retrying create, double-publishing the + // report. Report partial success via `notified: false`. + let notified = false; + if (input.notify) { + try { + await notifyStatusReport({ + ctx, + input: { statusReportUpdateId: result.initialUpdate.id }, + }); + notified = true; + } catch (err) { + logger.error( + "notifyStatusReport failed after create_status_report {*}", + { + err, + workspaceId: ctx.workspace.id, + statusReportId: result.statusReport.id, + statusReportUpdateId: result.initialUpdate.id, + }, + ); + } + } + return { ...result, notified }; + }, + ({ statusReport, initialUpdate, notified }) => { + const out = { + statusReport: { + id: statusReport.id, + title: statusReport.title, + status: statusReport.status, + pageId: statusReport.pageId, + createdAt: statusReport.createdAt?.toISOString() ?? null, + }, + initialUpdateId: initialUpdate.id, + notified, + }; + return { + content: [{ type: "text", text: JSON.stringify(out) }], + structuredContent: out, + }; + }, + ), + ), + ); + + registered.set( + "add_status_report_update", + server.registerTool( + "add_status_report_update", + { + description: + "Append a new public update to an existing status report. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS — irreversible side effects. Sets the report's status to the new value (use resolve_status_report instead if the new status would be 'resolved').\n\nMANDATORY workflow before calling:\n1. Draft the new status and message.\n2. Show the draft to the user for review.\n3. Ask explicitly: 'Should I notify subscribers about this update? yes/no'.\n4. Only call this tool once the user has confirmed BOTH the content AND the notify decision.\n\nSubscriber notifications dispatch as part of this call only — you cannot notify retroactively. There is NO separate notify tool. If notify is false here, this update will never reach subscribers. Note: the update persists even if the notify dispatch fails; the response's `notified` field reports whether subscribers were actually notified.", + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + inputSchema: { + statusReportId: z + .number() + .int() + .describe( + "Status report to update. Resolve via list_status_reports — do not guess.", + ), + status: statusReportStatusSchema.describe( + "New status to set on the report. The update entry inherits this status.", + ), + message: z + .string() + .min(1) + .describe("Public update message customers will see."), + date: z.iso + .datetime() + .optional() + .describe("Override the update's date. Defaults to now."), + notify: z + .boolean() + .describe( + "REQUIRED. Whether to dispatch subscriber notifications for this update. You MUST ask the user before deciding — do not infer. true = notify now. false = append silently; subscribers will NEVER be notified about this update (you cannot notify retroactively).", + ), + }, + outputSchema: { + statusReportUpdateId: z.number().int(), + notified: z.boolean(), + }, + }, + async (input) => + runTool( + async () => { + const result = await addStatusReportUpdate({ + ctx, + input: { + statusReportId: input.statusReportId, + status: input.status, + message: input.message, + date: input.date ? new Date(input.date) : undefined, + }, + }); + // See `create_status_report` for the rationale: the + // update is already persisted, so a notify dispatch + // failure must not propagate as a tool error. + let notified = false; + if (input.notify) { + try { + await notifyStatusReport({ + ctx, + input: { + statusReportUpdateId: result.statusReportUpdate.id, + }, + }); + notified = true; + } catch (err) { + logger.error( + "notifyStatusReport failed after add_status_report_update {*}", + { + err, + workspaceId: ctx.workspace.id, + statusReportId: result.statusReport.id, + statusReportUpdateId: result.statusReportUpdate.id, + }, + ); + } + } + return { ...result, notified }; + }, + ({ statusReportUpdate, notified }) => { + const out = { + statusReportUpdateId: statusReportUpdate.id, + notified, + }; + return { + content: [{ type: "text", text: JSON.stringify(out) }], + structuredContent: out, + }; + }, + ), + ), + ); + + registered.set( + "update_status_report", + server.registerTool( + "update_status_report", + { + description: + "Edit metadata on an existing status report (title, status, affected components). Does NOT add a public update — use add_status_report_update for that. Does NOT and CANNOT notify subscribers — there is no notify path on this tool because metadata edits do not create a new update entry to dispatch. If subscribers need to hear about a change, use add_status_report_update instead with notify: true. Audit-logged.\n\nMANDATORY workflow: draft the change, show it to the user, confirm before calling.", + annotations: { + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: { + statusReportId: z + .number() + .int() + .describe("Report to edit. Resolve via list_status_reports."), + title: z.string().min(1).max(256).optional().describe("New title."), + // Guard against `status: "resolved"` — that would flip the + // report's status without publishing a resolution update, + // leaving the public page in a divergent state (status + // reads "resolved" but no resolution message). Force the + // LLM to use resolve_status_report (which appends the + // final update) instead. The tool description already + // says this; the refine is the runtime backstop. + status: statusReportStatusSchema + .refine((s) => s !== "resolved", { + error: + "update_status_report cannot set status to 'resolved' — use resolve_status_report instead, which also publishes a final resolution update.", + }) + .optional() + .describe( + "New status (without appending a public update entry). Cannot be 'resolved' — use resolve_status_report for that.", + ), + pageComponentIds: z + .array(z.number().int()) + .optional() + .describe( + "Replace the full set of associated component ids. Empty array clears the association.", + ), + }, + outputSchema: { + id: z.number().int(), + title: z.string(), + status: statusReportStatusSchema, + }, + }, + async (input) => + runTool( + () => + updateStatusReport({ + ctx, + input: { + id: input.statusReportId, + title: input.title, + status: input.status, + pageComponentIds: input.pageComponentIds, + }, + }), + (report) => { + const out = { + id: report.id, + title: report.title, + status: report.status, + }; + return { + content: [{ type: "text", text: JSON.stringify(out) }], + structuredContent: out, + }; + }, + ), + ), + ); + + registered.set( + "resolve_status_report", + server.registerTool( + "resolve_status_report", + { + description: + "Resolve an active status report. Appends a final public update with the supplied message and flips status to 'resolved'. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS — irreversible side effects.\n\nMANDATORY workflow before calling:\n1. Draft the resolution message.\n2. Show the draft to the user for review.\n3. Ask explicitly: 'Should I notify subscribers that this incident is resolved? yes/no'.\n4. Only call this tool once the user has confirmed BOTH the message AND the notify decision.\n\nSubscriber notifications dispatch as part of this call only — you cannot notify retroactively. There is NO separate notify tool. If notify is false here, the resolution will never reach subscribers. Note: the report flips to resolved even if the notify dispatch fails; the response's `notified` field reports whether subscribers were actually notified.", + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + inputSchema: { + statusReportId: z + .number() + .int() + .describe("Report to resolve. Resolve via list_status_reports."), + message: z + .string() + .min(1) + .describe( + "Resolution message customers will see explaining what was fixed.", + ), + date: z.iso + .datetime() + .optional() + .describe("Override the resolution date. Defaults to now."), + notify: z + .boolean() + .describe( + "REQUIRED. Whether to dispatch subscriber notifications for the resolution update. You MUST ask the user before deciding — do not infer. true = notify now. false = resolve silently; subscribers will NEVER hear about this resolution (you cannot notify retroactively).", + ), + }, + outputSchema: { + statusReportUpdateId: z.number().int(), + notified: z.boolean(), + }, + }, + async (input) => + runTool( + async () => { + const result = await resolveStatusReport({ + ctx, + input: { + statusReportId: input.statusReportId, + message: input.message, + date: input.date ? new Date(input.date) : undefined, + }, + }); + // See `create_status_report` for the rationale: the + // resolution update is already persisted, so a notify + // dispatch failure must not propagate as a tool error. + let notified = false; + if (input.notify) { + try { + await notifyStatusReport({ + ctx, + input: { + statusReportUpdateId: result.statusReportUpdate.id, + }, + }); + notified = true; + } catch (err) { + logger.error( + "notifyStatusReport failed after resolve_status_report {*}", + { + err, + workspaceId: ctx.workspace.id, + statusReportId: result.statusReport.id, + statusReportUpdateId: result.statusReportUpdate.id, + }, + ); + } + } + return { ...result, notified }; + }, + ({ statusReportUpdate, notified }) => { + const out = { + statusReportUpdateId: statusReportUpdate.id, + notified, + }; + return { + content: [{ type: "text", text: JSON.stringify(out) }], + structuredContent: out, + }; + }, + ), + ), + ); + + return registered; +} diff --git a/apps/server/src/routes/mcp/tools/tools.test.ts b/apps/server/src/routes/mcp/tools/tools.test.ts new file mode 100644 index 00000000..4e77c81d --- /dev/null +++ b/apps/server/src/routes/mcp/tools/tools.test.ts @@ -0,0 +1,579 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db, eq } from "@openstatus/db"; +import { + page, + pageComponent, + pageSubscriber, + statusReport, + statusReportUpdate, +} from "@openstatus/db/src/schema"; +import type { Workspace } from "@openstatus/db/src/schema"; +import type { ServiceContext } from "@openstatus/services"; +import { + SEEDED_WORKSPACE_FREE_ID, + SEEDED_WORKSPACE_TEAM_ID, +} from "@openstatus/services/test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + readAuditLog, + withTestTransaction, +} from "@openstatus/services/test/helpers"; + +import { toServiceCtx } from "../adapter"; +import { registerMaintenanceTools } from "./maintenance"; +import { registerPageTools } from "./page"; +import { registerStatusReportTools } from "./status-report"; + +/** + * Build an MCP-flavoured `ServiceContext` for a workspace, optionally + * threading a transaction through `ctx.db` so writes can be rolled back + * via `withTestTransaction`. Local to this test file — distinct from + * `makeMcpCtx` in `@openstatus/services/test/helpers`, which doesn't + * thread a tx and exists for service-package tests. + */ +function makeMcpToolCtx( + workspace: Workspace, + opts: { + db?: ServiceContext["db"]; + createdById?: number; + } = {}, +): ServiceContext { + return { + ...toServiceCtx({ + workspace, + apiKey: { id: "test-key", createdById: opts.createdById }, + requestId: "test-req", + }), + db: opts.db, + }; +} + +const TEST_PREFIX = "mcp-tool-test"; + +let teamWorkspace: Workspace; +let testPageId: number; +let testPageComponentId: number; + +beforeAll(async () => { + teamWorkspace = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + const pageRow = await db + .insert(page) + .values({ + workspaceId: teamWorkspace.id, + title: `${TEST_PREFIX}-page`, + description: "test page", + slug: `${TEST_PREFIX}-page-slug`, + customDomain: "", + }) + .returning() + .get(); + testPageId = pageRow.id; + + const componentRow = await db + .insert(pageComponent) + .values({ + workspaceId: teamWorkspace.id, + pageId: testPageId, + name: `${TEST_PREFIX}-component`, + type: "static", + }) + .returning() + .get(); + testPageComponentId = componentRow.id; +}); + +afterAll(async () => { + await db + .delete(pageSubscriber) + .where(eq(pageSubscriber.pageId, testPageId)) + .catch(() => undefined); + await db + .delete(pageComponent) + .where(eq(pageComponent.id, testPageComponentId)) + .catch(() => undefined); + await db + .delete(page) + .where(eq(page.id, testPageId)) + .catch(() => undefined); +}); + +/** Build a fresh McpServer + register a tool group, return the tool map. */ +function registered( + group: "page" | "status-report" | "maintenance", + ctx: ServiceContext, +) { + const server = new McpServer( + { name: "test", version: "0.0.0" }, + { capabilities: { tools: { listChanged: false } } }, + ); + switch (group) { + case "page": + return registerPageTools(server, ctx); + case "status-report": + return registerStatusReportTools(server, ctx); + case "maintenance": + return registerMaintenanceTools(server, ctx); + } +} + +/** Invoke a tool's registered handler with input args. Returns the CallToolResult. */ +async function callTool( + toolMap: ReturnType, + name: string, + args: Record, +): Promise<{ + structuredContent?: unknown; + isError?: boolean; + content: unknown; +}> { + const tool = toolMap.get(name); + if (!tool) throw new Error(`tool ${name} not registered`); + // The SDK's `RegisteredTool.handler` signature accepts (args, extra) — + // we provide a minimal extra suitable for unit tests. + const extra = { + signal: new AbortController().signal, + requestId: "test-req", + sendNotification: async () => undefined, + sendRequest: async () => undefined as never, + // biome-ignore lint/suspicious/noExplicitAny: SDK types not relevant for unit test + } as any; + // biome-ignore lint/suspicious/noExplicitAny: handler is dynamically typed by the SDK + return (tool.handler as any)(args, extra); +} + +describe("list_status_pages", () => { + test("lists pages in the workspace, slim shape", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("page", ctx); + const result = await callTool(tools, "list_status_pages", {}); + expect(result.isError).toBeUndefined(); + const items = (result.structuredContent as { items: unknown[] }).items; + expect(Array.isArray(items)).toBe(true); + const ids = items.map((i) => (i as { id: number }).id); + expect(ids).toContain(testPageId); + // slim shape: must NOT carry password / customDomain etc. + const ourPage = items.find( + (i) => (i as { id: number }).id === testPageId, + ); + expect(Object.keys(ourPage as object).sort()).toEqual([ + "id", + "slug", + "title", + ]); + }); + }); + + test("does not leak pages from another workspace", async () => { + await withTestTransaction(async (tx) => { + // Insert a page in the FREE workspace using the same tx. + const otherPage = await tx + .insert(page) + .values({ + workspaceId: SEEDED_WORKSPACE_FREE_ID, + title: `${TEST_PREFIX}-other-ws`, + description: "should be invisible", + slug: `${TEST_PREFIX}-other-ws-slug`, + customDomain: "", + }) + .returning() + .get(); + + // Call list_status_pages as the TEAM workspace. + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("page", ctx); + const result = await callTool(tools, "list_status_pages", {}); + const items = (result.structuredContent as { items: { id: number }[] }) + .items; + const ids = items.map((i) => i.id); + expect(ids).not.toContain(otherPage.id); + }); + }); +}); + +describe("list_status_reports", () => { + test("filters out resolved reports by default", async () => { + await withTestTransaction(async (tx) => { + // seed: one active + one resolved + const active = await tx + .insert(statusReport) + .values({ + workspaceId: teamWorkspace.id, + pageId: testPageId, + title: `${TEST_PREFIX}-active`, + status: "investigating", + }) + .returning() + .get(); + const resolved = await tx + .insert(statusReport) + .values({ + workspaceId: teamWorkspace.id, + pageId: testPageId, + title: `${TEST_PREFIX}-resolved`, + status: "resolved", + }) + .returning() + .get(); + + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("status-report", ctx); + const result = await callTool(tools, "list_status_reports", { + filter: "active", + }); + expect(result.isError).toBeUndefined(); + const items = (result.structuredContent as { items: { id: number }[] }) + .items; + const ids = items.map((i) => i.id); + expect(ids).toContain(active.id); + expect(ids).not.toContain(resolved.id); + }); + }); +}); + +describe("create_status_report", () => { + test("creates a report + initial update and emits audit with transport=mcp", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("status-report", ctx); + const result = await callTool(tools, "create_status_report", { + title: `${TEST_PREFIX}-create`, + status: "investigating", + message: "investigating slowdown", + pageId: testPageId, + pageComponentIds: [testPageComponentId], + notify: false, + }); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { + statusReport: { id: number; title: string }; + initialUpdateId: number; + }; + expect(out.statusReport.id).toBeGreaterThan(0); + expect(out.statusReport.title).toBe(`${TEST_PREFIX}-create`); + expect(out.initialUpdateId).toBeGreaterThan(0); + + await expectAuditRow({ + workspaceId: teamWorkspace.id, + action: "status_report.create", + entityType: "status_report", + entityId: out.statusReport.id, + actorType: "mcp", + db: tx, + }); + + const rows = await readAuditLog({ + workspaceId: teamWorkspace.id, + entityType: "status_report", + entityId: out.statusReport.id, + db: tx, + }); + expect(rows[0]?.actorType).toBe("mcp"); + expect(rows[0]?.actorId).toBe("test-key"); + // No createdById on the test ctx → actorUserId stays null. + expect(rows[0]?.actorUserId).toBeNull(); + // notify defaulted false → no dispatch + expect(out).toMatchObject({ notified: false }); + }); + }); + + test("propagates createdById to audit_log.actor_user_id", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx, createdById: 1 }); + const tools = registered("status-report", ctx); + const result = await callTool(tools, "create_status_report", { + title: `${TEST_PREFIX}-with-creator`, + status: "investigating", + message: "x", + pageId: testPageId, + pageComponentIds: [], + notify: false, + }); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { + statusReport: { id: number }; + }; + const rows = await readAuditLog({ + workspaceId: teamWorkspace.id, + entityType: "status_report", + entityId: out.statusReport.id, + db: tx, + }); + expect(rows[0]?.actorUserId).toBe(1); + }); + }); + + test("notify: true reports notified back to caller", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("status-report", ctx); + const result = await callTool(tools, "create_status_report", { + title: `${TEST_PREFIX}-create-notify`, + status: "investigating", + message: "investigating slowdown", + pageId: testPageId, + pageComponentIds: [], + notify: true, + }); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { notified: boolean }; + // The team workspace's plan may or may not enable status-subscribers, + // but the notify call must run; the tool reports `notified: true` + // regardless of plan-level no-op behaviour inside the service. + expect(out.notified).toBe(true); + }); + }); + + test("returns isError: true when pageId is not in workspace (NOT_FOUND)", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("status-report", ctx); + const result = await callTool(tools, "create_status_report", { + title: `${TEST_PREFIX}-bad-page`, + status: "investigating", + message: "x", + pageId: 9_999_999, + pageComponentIds: [], + notify: false, + }); + expect(result.isError).toBe(true); + }); + }); + + // The "rejects calls that omit `notify`" guarantee belongs to the + // SDK's input-validation step — exercised in handler.test.ts via the + // real `tools/call` JSON-RPC envelope. A handler-direct invocation + // (this file's pattern) bypasses that validation, so a unit test + // here can't catch the missing-required-field case. +}); + +describe("add_status_report_update", () => { + test("appends an update and emits audit with transport=mcp", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const sr = await tx + .insert(statusReport) + .values({ + workspaceId: teamWorkspace.id, + pageId: testPageId, + title: `${TEST_PREFIX}-add-update`, + status: "investigating", + }) + .returning() + .get(); + + const tools = registered("status-report", ctx); + const result = await callTool(tools, "add_status_report_update", { + statusReportId: sr.id, + status: "identified", + message: "found root cause", + notify: false, + }); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { statusReportUpdateId: number }; + expect(out.statusReportUpdateId).toBeGreaterThan(0); + + const rows = await readAuditLog({ + workspaceId: teamWorkspace.id, + entityType: "status_report_update", + entityId: out.statusReportUpdateId, + db: tx, + }); + expect(rows[0]?.action).toBe("status_report_update.create"); + expect(rows[0]?.actorType).toBe("mcp"); + expect(rows[0]?.actorId).toBe("test-key"); + }); + }); +}); + +describe("update_status_report", () => { + test("edits a report's title and emits audit with transport=mcp", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const sr = await tx + .insert(statusReport) + .values({ + workspaceId: teamWorkspace.id, + pageId: testPageId, + title: `${TEST_PREFIX}-orig`, + status: "investigating", + }) + .returning() + .get(); + + const tools = registered("status-report", ctx); + const result = await callTool(tools, "update_status_report", { + statusReportId: sr.id, + title: `${TEST_PREFIX}-edited`, + }); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { id: number; title: string }; + expect(out.title).toBe(`${TEST_PREFIX}-edited`); + + const rows = await readAuditLog({ + workspaceId: teamWorkspace.id, + entityType: "status_report", + entityId: out.id, + db: tx, + }); + expect(rows[0]?.action).toBe("status_report.update"); + expect(rows[0]?.actorType).toBe("mcp"); + expect(rows[0]?.actorId).toBe("test-key"); + }); + }); + + // The "rejects status: 'resolved'" guarantee is enforced by a Zod + // refine on the input schema — see handler.test.ts for the + // integration test that exercises it via a real `tools/call`. A + // handler-direct invocation here bypasses the SDK's input + // validation step, so the refine doesn't fire. +}); + +describe("resolve_status_report", () => { + test("resolves an active report and emits audit with transport=mcp", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const sr = await tx + .insert(statusReport) + .values({ + workspaceId: teamWorkspace.id, + pageId: testPageId, + title: `${TEST_PREFIX}-resolve`, + status: "investigating", + }) + .returning() + .get(); + + const tools = registered("status-report", ctx); + const result = await callTool(tools, "resolve_status_report", { + statusReportId: sr.id, + message: "fixed", + notify: false, + }); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { statusReportUpdateId: number }; + expect(out.statusReportUpdateId).toBeGreaterThan(0); + + // Resolution path goes through addStatusReportUpdate which writes + // a status_report_update.create audit row. + const rows = await readAuditLog({ + workspaceId: teamWorkspace.id, + entityType: "status_report_update", + entityId: out.statusReportUpdateId, + db: tx, + }); + expect(rows[0]?.actorType).toBe("mcp"); + expect(rows[0]?.actorId).toBe("test-key"); + // confirm the report itself flipped to resolved + const after = await tx + .select() + .from(statusReport) + .where(eq(statusReport.id, sr.id)) + .get(); + expect(after?.status).toBe("resolved"); + // tidy up the inserted update row to keep the rolled-back tx tidy + await tx + .delete(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, sr.id)) + .catch(() => undefined); + }); + }); +}); + +describe("list_maintenances", () => { + test("returns items and pagination metadata", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("maintenance", ctx); + const result = await callTool(tools, "list_maintenances", {}); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { + items: unknown[]; + pagination: { + page: number; + perPage: number; + totalSize: number; + totalPages: number; + }; + }; + expect(Array.isArray(out.items)).toBe(true); + expect(out.pagination.page).toBe(1); + expect(out.pagination.perPage).toBe(50); + expect(typeof out.pagination.totalSize).toBe("number"); + expect(out.pagination.totalPages).toBeGreaterThanOrEqual(1); + }); + }); + + test("respects page and perPage inputs", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("maintenance", ctx); + const result = await callTool(tools, "list_maintenances", { + page: 2, + perPage: 10, + }); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { + pagination: { page: number; perPage: number }; + }; + expect(out.pagination.page).toBe(2); + expect(out.pagination.perPage).toBe(10); + }); + }); +}); + +describe("create_maintenance", () => { + test("creates a maintenance window and emits audit with transport=mcp", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("maintenance", ctx); + const from = new Date("2026-04-30T14:00:00Z").toISOString(); + const to = new Date("2026-04-30T15:00:00Z").toISOString(); + const result = await callTool(tools, "create_maintenance", { + title: `${TEST_PREFIX}-mtc`, + message: "scheduled work", + from, + to, + pageId: testPageId, + pageComponentIds: [testPageComponentId], + notify: false, + }); + expect(result.isError).toBeUndefined(); + const out = result.structuredContent as { id: number; title: string }; + expect(out.id).toBeGreaterThan(0); + expect(out.title).toBe(`${TEST_PREFIX}-mtc`); + + const rows = await readAuditLog({ + workspaceId: teamWorkspace.id, + entityType: "maintenance", + entityId: out.id, + db: tx, + }); + expect(rows[0]?.action).toBe("maintenance.create"); + expect(rows[0]?.actorType).toBe("mcp"); + expect(rows[0]?.actorId).toBe("test-key"); + }); + }); + + test("returns isError: true when from > to (VALIDATION)", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + const tools = registered("maintenance", ctx); + const from = new Date("2026-04-30T15:00:00Z").toISOString(); + const to = new Date("2026-04-30T14:00:00Z").toISOString(); + const result = await callTool(tools, "create_maintenance", { + title: `${TEST_PREFIX}-bad-range`, + message: "x", + from, + to, + pageId: testPageId, + pageComponentIds: [], + notify: false, + }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/apps/server/src/routes/rpc/adapter.ts b/apps/server/src/routes/rpc/adapter.ts index 03094973..b0c59466 100644 --- a/apps/server/src/routes/rpc/adapter.ts +++ b/apps/server/src/routes/rpc/adapter.ts @@ -5,17 +5,18 @@ import { ZodError } from "zod"; import type { RpcContext } from "./interceptors"; /** - * Translate Connect RPC auth context into a `ServiceContext`. - * - * TODO: Once the auth interceptor captures the API key id, expose it here - * as `actor.keyId`. Until then we synthesise `ws:` so audit - * records still have a non-empty actor identifier — see the plan's open - * question "Does Connect's auth interceptor capture API key ID today?". + * Translate Connect RPC auth context into a `ServiceContext`. The + * `apiKeyId` is the real key identifier captured by the auth + * interceptor — audit rows can attribute mutations to the specific key. */ export function toServiceCtx(rpcCtx: RpcContext): ServiceContext { return { workspace: rpcCtx.workspace, - actor: { type: "apiKey", keyId: `ws:${rpcCtx.workspace.id}` }, + actor: { + type: "apiKey", + keyId: rpcCtx.apiKey.id, + userId: rpcCtx.apiKey.createdById, + }, requestId: rpcCtx.requestId, }; } @@ -48,6 +49,8 @@ export function toConnectError(err: unknown): never { throw new ConnectError(err.message, Code.InvalidArgument); case "LIMIT_EXCEEDED": throw new ConnectError(err.message, Code.ResourceExhausted); + case "PRECONDITION_FAILED": + throw new ConnectError(err.message, Code.FailedPrecondition); case "INTERNAL": throw new ConnectError(err.message, Code.Internal); } diff --git a/apps/server/src/routes/rpc/interceptors/auth.ts b/apps/server/src/routes/rpc/interceptors/auth.ts index 90406f24..5f744a2a 100644 --- a/apps/server/src/routes/rpc/interceptors/auth.ts +++ b/apps/server/src/routes/rpc/interceptors/auth.ts @@ -16,6 +16,12 @@ import { lookupWorkspace, validateKey } from "@/libs/middlewares/auth"; export interface RpcContext { workspace: Workspace; requestId: string; + /** + * Resolved API key identity. `id` is the stable key identifier (audit + * `actor_id`); `createdById` is the openstatus user who created the + * key (`api_key.created_by_id`, audit `actor_user_id`). + */ + apiKey: { id: string; createdById?: number }; } /** @@ -69,10 +75,17 @@ export function authInterceptor(): Interceptor { // Generate request ID if not provided const requestId = req.header.get("x-request-id") ?? nanoid(); - // Store context for handlers to access + // Store context for handlers to access. `keyId` falls back to a + // workspace-scoped placeholder when `validateKey` couldn't capture + // a stable id (shouldn't happen post-migration, but keeps this + // safe). const rpcContext: RpcContext = { workspace, requestId, + apiKey: { + id: result.keyId ?? `ws:${workspace.id}`, + createdById: result.createdById, + }, }; // Set context using ConnectRPC's context values diff --git a/apps/server/src/types/index.ts b/apps/server/src/types/index.ts index 7c7487db..bb76ad7d 100644 --- a/apps/server/src/types/index.ts +++ b/apps/server/src/types/index.ts @@ -4,4 +4,14 @@ import type { RequestIdVariables } from "hono/request-id"; export type Variables = RequestIdVariables & { workspace: Workspace; event: Record; + /** + * Resolved API key identity. Populated by `authMiddleware` after a + * successful key check. Adapters thread `id` into + * `ServiceContext.actor.keyId` so audit rows attribute mutations to + * the specific key (not the workspace). `createdById` is the + * openstatus user who created the key (`api_key.created_by_id`) — + * propagated to `actor.userId` so `audit_log.actor_user_id` is + * populated for custom keys. + */ + apiKey: { id: string; createdById?: number }; }; diff --git a/apps/web/src/app/(landing)/compare/page.tsx b/apps/web/src/app/(landing)/compare/page.tsx index effc3570..1348efde 100644 --- a/apps/web/src/app/(landing)/compare/page.tsx +++ b/apps/web/src/app/(landing)/compare/page.tsx @@ -13,7 +13,7 @@ import { ContentBoxUrl, } from "../content-box"; -const TITLE = "Compare Uptime Monitoring & Status Page Alternatives"; +const TITLE = "Compare openstatus with uptime and status page solutions"; const DESCRIPTION = "See how openstatus compares to BetterStack, UptimeRobot, Checkly, Instatus, and other monitoring tools. Side-by-side feature and pricing comparisons to help you choose the right solution."; @@ -40,7 +40,7 @@ export const metadata: Metadata = { export default function Page() { return (
-

Compare openstatus with uptime and status page solutions

+

{TITLE}

{getComparePages().map((page) => ( diff --git a/apps/web/src/app/(landing)/tooling/[slug]/page.tsx b/apps/web/src/app/(landing)/tooling/[slug]/page.tsx new file mode 100644 index 00000000..c25c1f47 --- /dev/null +++ b/apps/web/src/app/(landing)/tooling/[slug]/page.tsx @@ -0,0 +1,80 @@ +import { CustomMDX } from "@/content/mdx"; +import { getToolingPages } from "@/content/utils"; +import { BASE_URL, getPageMetadata } from "@/lib/metadata/shared-metadata"; +import { + createJsonLDGraph, + getJsonLDBreadcrumbList, + getJsonLDFAQPage, + getJsonLDHowTo, + getJsonLDOrganization, + getJsonLDWebPage, +} from "@/lib/metadata/structured-data"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +export const dynamicParams = false; + +export async function generateStaticParams() { + const pages = getToolingPages(); + + return pages.map((page) => ({ + slug: page.slug, + })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const page = getToolingPages().find((page) => page.slug === slug); + if (!page) { + return; + } + + const metadata = getPageMetadata(page, "tooling"); + + return metadata; +} + +export default async function ToolingPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const page = getToolingPages().find((page) => page.slug === slug); + + if (!page) { + notFound(); + } + + const jsonLDGraph = createJsonLDGraph([ + getJsonLDOrganization(), + getJsonLDWebPage(page), + getJsonLDBreadcrumbList([ + { name: "Home", url: BASE_URL }, + { name: "Tooling", url: `${BASE_URL}/tooling` }, + { name: page.metadata.title, url: `${BASE_URL}/tooling/${slug}` }, + ]), + getJsonLDHowTo(page), + getJsonLDFAQPage(page), + ]); + + return ( +
+