From 658674e18b099b94b908fc1e84f3ed05faac4704 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:03:54 +0200 Subject: [PATCH] fix: isitagentready (#2126) * fix: isitagentready * fix: review * fix: review * fix: details id --- apps/web/next.config.ts | 48 ++++- .../agent-skills/openstatus-api/SKILL.md | 43 +++++ .../agent-skills/openstatus-mcp/SKILL.md | 49 +++++ apps/web/public/robots.txt | 9 + .../agent-skills/index.json/route.ts | 77 ++++++++ .../src/app/.well-known/api-catalog/route.ts | 70 +++++++ .../.well-known/mcp/server-card.json/route.ts | 90 +++++++++ .../src/app/.well-known/security.txt/route.ts | 31 ++++ apps/web/src/app/layout.tsx | 2 + apps/web/src/app/robots.ts | 11 -- apps/web/src/components/webmcp-provider.tsx | 171 ++++++++++++++++++ .../src/content/mdx-components/details.tsx | 3 +- 12 files changed, 591 insertions(+), 13 deletions(-) create mode 100644 apps/web/public/.well-known/agent-skills/openstatus-api/SKILL.md create mode 100644 apps/web/public/.well-known/agent-skills/openstatus-mcp/SKILL.md create mode 100644 apps/web/public/robots.txt create mode 100644 apps/web/src/app/.well-known/agent-skills/index.json/route.ts create mode 100644 apps/web/src/app/.well-known/api-catalog/route.ts create mode 100644 apps/web/src/app/.well-known/mcp/server-card.json/route.ts create mode 100644 apps/web/src/app/.well-known/security.txt/route.ts delete mode 100644 apps/web/src/app/robots.ts create mode 100644 apps/web/src/components/webmcp-provider.tsx diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 44e62a1a..9fc54162 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,24 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; +// Read the MCP server version at build time from apps/server/package.json so the +// `serverInfo.version` we publish in /.well-known/mcp/server-card.json never drifts. +// Falls back to "0.0.0" if the sibling app isn't present in the build context (e.g. +// a deploy that excludes apps/server). Exposed to runtime via Next's `env` config. +function readMcpServerVersion(): string { + try { + const pkgPath = join(__dirname, "..", "server", "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { + version?: string; + }; + return pkg.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} + // REMINDER: avoid Clickjacking attacks by setting the frame-ancestors directive const securityHeaders = [ { @@ -9,15 +27,40 @@ const securityHeaders = [ }, ]; +// Link headers for agent discovery (RFC 8288 / RFC 8631). +// service-doc: human-readable docs. service-desc: machine-readable API description. +const homepageLinkHeader = [ + '; rel="api-catalog"; type="application/linkset+json"', + '; rel="agent-skills"; type="application/json"', + '; rel="service-doc"; type="text/html"', + '; rel="service-desc"; type="application/json"', + '; rel="describedby"; type="text/plain"', + '; rel="terms-of-service"', + '; rel="privacy-policy"', +].join(", "); + +const agentDiscoveryHeaders = [ + { + key: "Link", + value: homepageLinkHeader, + }, +]; + /** @type {import('next').NextConfig} */ const nextConfig: NextConfig = { reactStrictMode: true, transpilePackages: ["@openstatus/ui", "@openstatus/api", "next-mdx-remote"], + env: { + OPENSTATUS_MCP_SERVER_VERSION: readMcpServerVersion(), + }, outputFileTracingIncludes: { "/": [ "./node_modules/.pnpm/@google-cloud/tasks/build/esm/src/**/*.json", "./node_modules/@google-cloud/tasks/build/esm/src/**/*.js", ], + "/.well-known/agent-skills/index.json": [ + "./public/.well-known/agent-skills/**/*.md", + ], }, experimental: { turbopackScopeHoisting: false, @@ -47,7 +90,10 @@ const nextConfig: NextConfig = { ], }, async headers() { - return [{ source: "/(.*)", headers: securityHeaders }]; + return [ + { source: "/(.*)", headers: securityHeaders }, + { source: "/", headers: agentDiscoveryHeaders }, + ]; }, async redirects() { return [ diff --git a/apps/web/public/.well-known/agent-skills/openstatus-api/SKILL.md b/apps/web/public/.well-known/agent-skills/openstatus-api/SKILL.md new file mode 100644 index 00000000..18456c76 --- /dev/null +++ b/apps/web/public/.well-known/agent-skills/openstatus-api/SKILL.md @@ -0,0 +1,43 @@ +--- +name: openstatus-api +description: Call the openstatus public API (ConnectRPC, JSON over HTTP) to manage monitors, status pages, status reports, and maintenance windows. Use when building integrations, automating monitoring as code, or driving openstatus from a script outside an MCP client. +--- + +# openstatus API + +The openstatus API is a typed, JSON-over-HTTP layer powered by ConnectRPC. The base URL is `https://api.openstatus.dev`. Every action the dashboard performs is reachable from the API — same workspace, same audit log, same single API key. + +## Auth + +Send the API key as the `x-openstatus-key` header. Generate one in **Settings → API Tokens**. + +``` +x-openstatus-key: os_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +## Example: list monitors + +```bash +curl https://api.openstatus.dev/rpc/openstatus.v1.MonitorService/ListMonitors \ + -X POST \ + -H "Content-Type: application/json" \ + -H "x-openstatus-key: $OPENSTATUS_API_KEY" \ + -d '{}' +``` + +ConnectRPC accepts both JSON and protobuf. POST is required even for read operations. + +## Schema + +- OpenAPI explorer (machine-readable description): +- Reference docs: + +## SDKs + +- Node SDK: +- Terraform provider: see + +## When to use this vs MCP + +- API → programmatic integrations, CI/CD, scripts. +- MCP server (`openstatus-mcp` skill) → chat-shaped AI clients that need to read and update status pages. diff --git a/apps/web/public/.well-known/agent-skills/openstatus-mcp/SKILL.md b/apps/web/public/.well-known/agent-skills/openstatus-mcp/SKILL.md new file mode 100644 index 00000000..8d6dfcfb --- /dev/null +++ b/apps/web/public/.well-known/agent-skills/openstatus-mcp/SKILL.md @@ -0,0 +1,49 @@ +--- +name: openstatus-mcp +description: Use the openstatus MCP server to read and update status pages, status reports, and maintenance windows from any Model Context Protocol client (Claude, ChatGPT, Cursor, etc.). Use when an AI assistant needs to post an incident, append an update, resolve a report, or schedule maintenance for an openstatus workspace. +--- + +# openstatus MCP server + +The openstatus MCP server is a remote, streamable-HTTP endpoint at `https://api.openstatus.dev/mcp`. It exposes 8 tools scoped to a single workspace. + +## Connect + +Add this to your MCP client config (Claude Desktop, Cursor, ChatGPT custom connector, etc.): + +```json +{ + "mcpServers": { + "openstatus": { + "url": "https://api.openstatus.dev/mcp", + "headers": { + "x-openstatus-key": "os_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + } +} +``` + +The `x-openstatus-key` header is the same API key used by the CLI, REST API, and Terraform provider. Generate one in **Settings → API Tokens**. There is no separate MCP OAuth flow. + +## Tools + +- `list_status_pages` — discover pages the workspace owns. +- `list_status_reports` — read incidents on a page. +- `create_status_report` — open a new incident. +- `append_status_report_update` — post an update on an open incident. +- `resolve_status_report` — close an incident. +- `update_status_report` — edit incident metadata. +- `create_maintenance` — schedule a maintenance window. +- `update_maintenance` — adjust a scheduled window. + +Every mutation tool requires an explicit `notify: true | false` argument. The model must decide whether subscribers are paged. There is no implicit default. + +## Audit + +All MCP mutations are written to the workspace audit log with `actor_type = 'mcp'`, so any change can be traced back to a key, a user, and the MCP transport. + +## Reference + +- Full tool schemas and error codes: +- Product overview: diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 00000000..5527f605 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,9 @@ +# Content Signals — declare AI content usage preferences +# https://contentsignals.org/ +# https://datatracker.ietf.org/doc/draft-romm-aipref-contentsignals/ +Content-Signal: search=yes, ai-input=yes, ai-train=no + +User-agent: * +Allow: / + +Sitemap: https://www.openstatus.dev/sitemap.xml diff --git a/apps/web/src/app/.well-known/agent-skills/index.json/route.ts b/apps/web/src/app/.well-known/agent-skills/index.json/route.ts new file mode 100644 index 00000000..f8f1b852 --- /dev/null +++ b/apps/web/src/app/.well-known/agent-skills/index.json/route.ts @@ -0,0 +1,77 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +// Agent Skills Discovery v0.2.0 index. +// SKILL.md files live in `public/.well-known/agent-skills//SKILL.md` and are +// hashed at request time so the `digest` can never drift from the served content. + +export const runtime = "nodejs"; + +type SkillEntry = { + name: string; + type: "skill"; + description: string; + slug: string; +}; + +const SKILLS: SkillEntry[] = [ + { + name: "openstatus-mcp", + type: "skill", + description: + "Connect a Model Context Protocol client (Claude, ChatGPT, Cursor) to an openstatus workspace and drive status pages, status reports, and maintenance windows from chat.", + slug: "openstatus-mcp", + }, + { + name: "openstatus-api", + type: "skill", + description: + "Call the openstatus ConnectRPC API to manage monitors, status pages, and incidents from scripts, CI/CD, and custom integrations.", + slug: "openstatus-api", + }, +]; + +const PUBLIC_DIR = join(process.cwd(), "public", ".well-known", "agent-skills"); +const SITE_ORIGIN = "https://www.openstatus.dev"; + +async function digestFor(slug: string): Promise { + try { + const buf = await readFile(join(PUBLIC_DIR, slug, "SKILL.md")); + return `sha256:${createHash("sha256").update(buf).digest("hex")}`; + } catch (err) { + console.warn(`[agent-skills] could not hash ${slug}/SKILL.md`, err); + return null; + } +} + +export async function GET() { + const resolved = await Promise.all( + SKILLS.map(async (s) => { + const digest = await digestFor(s.slug); + if (!digest) return null; + return { + name: s.name, + type: s.type, + description: s.description, + url: `${SITE_ORIGIN}/.well-known/agent-skills/${s.slug}/SKILL.md`, + digest, + }; + }), + ); + const skills = resolved.filter((s): s is NonNullable => s !== null); + + const body = JSON.stringify({ + $schema: "https://agentskills.io/schemas/discovery/v0.2.0.json", + version: "0.2.0", + publisher: { name: "Openstatus", url: SITE_ORIGIN }, + skills, + }); + + return new Response(body, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/apps/web/src/app/.well-known/api-catalog/route.ts b/apps/web/src/app/.well-known/api-catalog/route.ts new file mode 100644 index 00000000..e32c3732 --- /dev/null +++ b/apps/web/src/app/.well-known/api-catalog/route.ts @@ -0,0 +1,70 @@ +// API catalog for automated API discovery — RFC 9727. +// Served as application/linkset+json per RFC 9264. +const linkset = { + linkset: [ + { + anchor: "https://api.openstatus.dev", + "service-desc": [ + { + href: "https://api.openstatus.dev/openapi", + type: "application/json", + title: "Openstatus API — OpenAPI description", + }, + ], + "service-doc": [ + { + href: "https://docs.openstatus.dev", + type: "text/html", + title: "Openstatus documentation", + }, + ], + status: [ + { + href: "https://status.openstatus.dev", + type: "text/html", + title: "Openstatus status page", + }, + ], + "terms-of-service": [ + { + href: "https://www.openstatus.dev/terms", + type: "text/html", + }, + ], + "privacy-policy": [ + { + href: "https://www.openstatus.dev/privacy", + type: "text/html", + }, + ], + }, + { + anchor: "https://api.openstatus.dev/mcp", + "service-desc": [ + { + href: "https://www.openstatus.dev/.well-known/mcp/server-card.json", + type: "application/json", + title: "Openstatus MCP server card (SEP-1649)", + }, + ], + "service-doc": [ + { + href: "https://docs.openstatus.dev/reference/mcp-server/", + type: "text/html", + title: "Openstatus MCP server reference", + }, + ], + }, + ], +}; + +const body = JSON.stringify(linkset); + +export function GET() { + return new Response(body, { + headers: { + "Content-Type": "application/linkset+json", + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/apps/web/src/app/.well-known/mcp/server-card.json/route.ts b/apps/web/src/app/.well-known/mcp/server-card.json/route.ts new file mode 100644 index 00000000..ef70ae69 --- /dev/null +++ b/apps/web/src/app/.well-known/mcp/server-card.json/route.ts @@ -0,0 +1,90 @@ +// MCP Server Card — SEP-1649 (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127). +// Served at /.well-known/mcp/server-card.json. +// +// `OPENSTATUS_MCP_SERVER_VERSION` is read from apps/server/package.json at build time +// in next.config.ts and inlined via Next's `env` config — no manual sync. +const MCP_SERVER_VERSION = process.env.OPENSTATUS_MCP_SERVER_VERSION ?? "0.0.1"; + +const card = { + $schema: "https://modelcontextprotocol.io/schemas/server-card/draft.json", + serverInfo: { + name: "openstatus", + version: MCP_SERVER_VERSION, + title: "Openstatus", + description: + "Read and update openstatus status pages, status reports, and maintenance windows from any Model Context Protocol client.", + homepage: "https://www.openstatus.dev/tooling/mcp-server", + documentation: "https://docs.openstatus.dev/reference/mcp-server/", + vendor: { name: "Openstatus", url: "https://www.openstatus.dev" }, + license: "AGPL-3.0", + }, + transport: { + type: "streamable-http", + url: "https://api.openstatus.dev/mcp", + }, + authentication: { + type: "apiKey", + in: "header", + name: "x-openstatus-key", + description: + "Workspace API key. Generate one in Settings → API Tokens. Same credential used by the CLI, REST API, and Terraform provider.", + }, + capabilities: { + tools: { listChanged: false }, + }, + tools: [ + { + name: "list_status_pages", + description: "List status pages in the workspace.", + }, + { + name: "list_status_reports", + description: "List status reports for a given status page.", + }, + { + name: "create_status_report", + description: + "Open a new status report (incident). Requires explicit notify: true | false.", + }, + { + name: "append_status_report_update", + description: + "Append an update to an open status report. Requires explicit notify: true | false.", + }, + { + name: "resolve_status_report", + description: + "Resolve an open status report. Requires explicit notify: true | false.", + }, + { + name: "update_status_report", + description: "Edit metadata on an existing status report.", + }, + { + name: "create_maintenance", + description: + "Schedule a maintenance window. Requires explicit notify: true | false.", + }, + { + name: "update_maintenance", + description: "Adjust a scheduled maintenance window.", + }, + ], + links: { + "service-doc": "https://docs.openstatus.dev/reference/mcp-server/", + "service-desc": "https://api.openstatus.dev/openapi", + "terms-of-service": "https://www.openstatus.dev/terms", + "privacy-policy": "https://www.openstatus.dev/privacy", + }, +}; + +const body = JSON.stringify(card); + +export function GET() { + return new Response(body, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/apps/web/src/app/.well-known/security.txt/route.ts b/apps/web/src/app/.well-known/security.txt/route.ts new file mode 100644 index 00000000..7bce6abb --- /dev/null +++ b/apps/web/src/app/.well-known/security.txt/route.ts @@ -0,0 +1,31 @@ +// security.txt — RFC 9116. +// `Expires` is computed on every request as (now + 90 days) so the file can +// never go stale: as long as the site is being deployed and served, the value +// is fresh. RFC 9116 requires Expires <= 1 year; 90 days is well within that. + +const EXPIRY_DAYS = 90; + +export const dynamic = "force-dynamic"; + +export function GET() { + const expires = new Date( + Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); + + const body = [ + "# Openstatus security contact — RFC 9116", + "Contact: mailto:ping@openstatus.dev", + `Expires: ${expires}`, + "Preferred-Languages: en", + "Canonical: https://www.openstatus.dev/.well-known/security.txt", + "Policy: https://github.com/openstatusHQ/openstatus/security/policy", + "", + ].join("\n"); + + return new Response(body, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=86400", + }, + }); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c871e0d1..2a53c414 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Inter } from "next/font/google"; import LocalFont from "next/font/local"; import { ThemeProvider } from "@/components/theme-provider"; +import { WebMcpProvider } from "@/components/webmcp-provider"; import { env } from "@/env"; import { defaultMetadata, @@ -51,6 +52,7 @@ export default function RootLayout({ {children} + diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts deleted file mode 100644 index 4d8cbb3c..00000000 --- a/apps/web/src/app/robots.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { MetadataRoute } from "next"; - -export default function robots(): MetadataRoute.Robots { - return { - rules: { - userAgent: "*", - allow: "/", - }, - sitemap: "https://www.openstatus.dev/sitemap.xml", - }; -} diff --git a/apps/web/src/components/webmcp-provider.tsx b/apps/web/src/components/webmcp-provider.tsx new file mode 100644 index 00000000..bfcf20db --- /dev/null +++ b/apps/web/src/components/webmcp-provider.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect } from "react"; + +// WebMCP — expose homepage actions to in-browser AI agents. +// https://webmachinelearning.github.io/webmcp/ +// https://developer.chrome.com/blog/webmcp-epp +// +// The API is unstable and only available in browsers shipping the prototype. +// We feature-detect navigator.modelContext and no-op everywhere else. + +type JSONSchema = { + type: "object"; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +}; + +type WebMcpTool = { + name: string; + description: string; + inputSchema: JSONSchema; + execute: (args: Record) => Promise | unknown; +}; + +type ModelContext = { + provideContext: (ctx: { tools: WebMcpTool[] }) => void; +}; + +declare global { + interface Navigator { + modelContext?: ModelContext; + } +} + +const navigateTo = (path: string) => { + if (typeof window === "undefined") return { ok: false }; + window.location.assign(path); + return { ok: true, url: new URL(path, window.location.origin).toString() }; +}; + +const tools: WebMcpTool[] = [ + { + name: "open_dashboard", + description: + "Open the openstatus dashboard (signed-in app). Use when the user wants to log in, sign up, or manage their workspace.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + execute: () => navigateTo("https://app.openstatus.dev"), + }, + { + name: "view_pricing", + description: + "Navigate to the openstatus pricing page. Use when the user asks how much openstatus costs or which plan to choose.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + execute: () => navigateTo("/pricing"), + }, + { + name: "open_docs", + description: + "Open the openstatus documentation site (docs.openstatus.dev). Use when the user wants reference material, API docs, or how-to guides.", + inputSchema: { + type: "object", + properties: { + topic: { + type: "string", + description: + "Optional search term to focus the docs landing (e.g. 'mcp', 'cli', 'terraform').", + }, + }, + additionalProperties: false, + }, + execute: ({ topic }) => { + const base = "https://docs.openstatus.dev"; + if (typeof topic === "string" && topic.trim().length > 0) { + return navigateTo(`${base}?q=${encodeURIComponent(topic.trim())}`); + } + return navigateTo(base); + }, + }, + { + name: "view_changelog", + description: + "Navigate to the openstatus changelog. Use when the user asks about recent updates, releases, or what's new.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + execute: () => navigateTo("/changelog"), + }, + { + name: "view_blog", + description: "Navigate to the openstatus blog index.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + execute: () => navigateTo("/blog"), + }, + { + name: "view_status", + description: + "Open the openstatus public status page so the user can see whether the platform itself is healthy.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + execute: () => navigateTo("https://status.openstatus.dev"), + }, + { + name: "book_call", + description: + "Open the calendar to book a call with the openstatus team. Use for sales, demos, or onboarding requests.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + execute: () => navigateTo("/cal"), + }, + { + name: "open_github", + description: + "Open the openstatus GitHub repository. Use when the user wants source code, issues, or to self-host.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + execute: () => navigateTo("/github"), + }, + { + name: "open_discord", + description: + "Open the openstatus Discord community for support and discussion.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + execute: () => navigateTo("/discord"), + }, +]; + +export function WebMcpProvider() { + useEffect(() => { + if (typeof navigator === "undefined") return; + const ctx = navigator.modelContext; + if (!ctx?.provideContext) return; + try { + ctx.provideContext({ tools }); + } catch (err) { + // Spec is in flight — surface the error in dev, swallow in prod. + if (process.env.NODE_ENV !== "production") { + console.warn("[webmcp] provideContext failed", err); + } + } + }, []); + + return null; +} diff --git a/apps/web/src/content/mdx-components/details.tsx b/apps/web/src/content/mdx-components/details.tsx index 1bb488fd..d3a6fd1a 100644 --- a/apps/web/src/content/mdx-components/details.tsx +++ b/apps/web/src/content/mdx-components/details.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { slugify } from "./heading"; export function Details({ children, @@ -10,7 +11,7 @@ export function Details({ open?: boolean; }) { return ( -
+
{summary} {React.isValidElement(children) ? // biome-ignore lint/suspicious/noExplicitAny: -- 2.51.2