diff --git a/apps/server/src/routes/mcp/handler.test.ts b/apps/server/src/routes/mcp/handler.test.ts index 125fba7e9..b6660edd9 100644 --- a/apps/server/src/routes/mcp/handler.test.ts +++ b/apps/server/src/routes/mcp/handler.test.ts @@ -1,9 +1,17 @@ import { sentry } from "@hono/sentry"; +import { Events } from "@openstatus/analytics"; import { db, desc, eq } from "@openstatus/db"; import { auditLog, page, statusReport } from "@openstatus/db/src/schema"; import { SEEDED_WORKSPACE_TEAM_ID } from "@openstatus/services/test/fixtures"; +import type { MockFn } from "@openstatus/test-utils"; import { expect } from "@std/expect"; -import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + test, +} from "@std/testing/bdd"; import { Hono } from "hono"; import { requestId } from "hono/request-id"; @@ -227,6 +235,73 @@ describe("MCP transport", () => { }); }); +/** + * `@openstatus/analytics` is swapped for a double (test.importmap.json) whose + * spies live on `globalThis.__analyticsSpies`. + */ +const analyticsSpies = (globalThis as Record) + .__analyticsSpies as { track: MockFn; setupAnalytics: MockFn }; + +/** + * Tracking is fire-and-forget — `track` runs in a microtask chained off + * `setupAnalytics`, which may not have settled when `app.fetch` resolves. + */ +async function flushTracking() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("MCP transport — analytics", () => { + beforeEach(() => { + analyticsSpies.setupAnalytics.mockClear(); + analyticsSpies.track.mockClear(); + }); + + test("tools/call tracks mcp_request with the tool name", async () => { + const app = makeApp(); + await app.fetch( + jsonRpc({ + method: "tools/call", + params: { name: "list_status_pages", arguments: {} }, + }), + ); + await flushTracking(); + + const identify = analyticsSpies.setupAnalytics.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(identify?.userId).toBe(`api_${SEEDED_WORKSPACE_TEAM_ID}`); + + const event = analyticsSpies.track.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(event?.name).toBe(Events.McpRequest.name); + expect(event?.method).toBe("tools/call"); + expect(event?.tool).toBe("list_status_pages"); + expect(event?.authenticated).toBe(true); + }); + + test("an anonymous call is tracked without a profile", async () => { + const app = makeApp(); + await app.fetch(jsonRpc({ method: "resources/list" }, false)); + await flushTracking(); + + const identify = analyticsSpies.setupAnalytics.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(identify?.userId).toBeUndefined(); + + const event = analyticsSpies.track.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(event?.method).toBe("resources/list"); + expect(event?.authenticated).toBe(false); + }); +}); + /** * End-to-end actor stamping — drive a real `tools/call` through * `app.fetch` and verify the resulting audit row has diff --git a/apps/server/src/routes/mcp/index.ts b/apps/server/src/routes/mcp/index.ts index 97206f09f..75209302c 100644 --- a/apps/server/src/routes/mcp/index.ts +++ b/apps/server/src/routes/mcp/index.ts @@ -1,4 +1,6 @@ import { StreamableHTTPTransport } from "@hono/mcp"; +import { getLogger } from "@logtape/logtape"; +import { Events, setupAnalytics } from "@openstatus/analytics"; import type { Workspace } from "@openstatus/db/src/schema"; import { resourceMetadataUrl } from "@openstatus/services/oauth"; import { Hono } from "hono"; @@ -11,6 +13,8 @@ import { oauthConfigFromEnv } from "../oauth/config"; import { toServiceCtx } from "./adapter"; import { createMcpServer, createPublicMcpServer } from "./server"; +const logger = getLogger("api-server"); + export const mcpRoute = new Hono<{ Variables: Variables }>({ strict: false }); const wwwAuthenticate = `Bearer resource_metadata="${resourceMetadataUrl(oauthConfigFromEnv().issuer)}"`; @@ -55,6 +59,74 @@ async function optionalAuthMiddleware( mcpRoute.use("*", optionalAuthMiddleware); +/** + * The JSON-RPC calls carried by a request body, as OpenPanel event properties. + * A batch arrives as an array and every entry is its own call; `params.name` is + * the tool for `tools/call`, `params.uri` the document for `resources/read`. + */ +function rpcCalls(body: unknown): Record[] { + const entries = Array.isArray(body) ? body : [body]; + const calls: Record[] = []; + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) continue; + const { method, params } = entry as { method?: unknown; params?: unknown }; + if (typeof method !== "string") continue; + const { name, uri } = (params ?? {}) as { name?: unknown; uri?: unknown }; + calls.push({ + method, + ...(typeof name === "string" ? { tool: name } : {}), + ...(typeof uri === "string" ? { uri } : {}), + }); + } + return calls; +} + +/** + * Fire-and-forget OpenPanel event for every JSON-RPC call that reaches the + * transport, so MCP traffic is countable alongside the REST and RPC surfaces. + * Emitted before execution: this measures calls, not successes, so a tool that + * throws still shows up in the volume. + * + * Authenticated requests reuse the `api_` profile the RPC tracking + * interceptor writes to — one identity per workspace across both programmatic + * surfaces, with the `mcp` channel telling them apart. Anonymous requests (an + * `initialize` before the client has a credential, or a public `resources/read`) + * carry no profile: `setupAnalytics` skips `identify` without a `userId` and the + * event still lands, marked `authenticated: false`. + */ +function trackMcpRequest( + c: Context<{ Variables: Variables }, "/*">, + workspace: Workspace | undefined, + body: unknown, +) { + const calls = rpcCalls(body); + if (calls.length === 0) return; + + setupAnalytics({ + userId: workspace ? `api_${workspace.id}` : undefined, + workspaceId: workspace ? `${workspace.id}` : undefined, + plan: workspace?.plan, + location: c.req.header("x-forwarded-for"), + userAgent: c.req.header("user-agent"), + }) + .then((analytics) => + Promise.all( + calls.map((call) => + analytics.track({ + ...Events.McpRequest, + ...call, + authenticated: workspace !== undefined, + }), + ), + ), + ) + .catch(() => { + logger.warn("Failed to send MCP analytics event for {methods}", { + methods: calls.map((call) => call.method), + }); + }); +} + /** * The transport handler MUST return a JSON-RPC error envelope on * unexpected throws — Hono's default `app.onError(handleError)` returns @@ -90,6 +162,8 @@ mcpRoute.all("/", async (c) => { } } + trackMcpRequest(c, workspace, parsedBody); + // 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 diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index 114a64fb8..d511cd406 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -220,4 +220,8 @@ export const Events = { name: "cdn_checker", channel: "checker", }, + McpRequest: { + name: "mcp_request", + channel: "mcp", + }, } as const satisfies Record;