diff --git a/apps/server/src/routes/mcp/handler.test.ts b/apps/server/src/routes/mcp/handler.test.ts index 125fba7e..b6660edd 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 97206f09..75209302 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 114a64fb..d511cd40 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; diff --git a/packages/analytics/src/server.ts b/packages/analytics/src/server.ts index b0505169..b125203b 100644 --- a/packages/analytics/src/server.ts +++ b/packages/analytics/src/server.ts @@ -3,22 +3,23 @@ import { OpenPanel, type TrackProperties } from "@openpanel/sdk"; import { env } from "../env"; import type { EventProps } from "./events"; -// Lazily instantiate so importing this module has no side effects — a top-level -// `new OpenPanel()` runs the node SDK at import time, which breaks bundling the -// tRPC context into the Edge runtime. -let client: OpenPanel | undefined; - -function getClient() { - if (!client) { - client = new OpenPanel({ - clientId: env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID, - clientSecret: env.OPENPANEL_CLIENT_SECRET, - }); - client.setGlobalProperties({ - env: process.env.VERCEL_ENV || env.NODE_ENV || "localhost", - // app_version - }); - } +// Instantiated per call rather than shared: the OpenPanel client carries +// per-request mutable state (the `x-client-ip`/`user-agent` headers set below +// and the `profileId` that `identify()` stores and `track()` reads back), so a +// module-level singleton lets concurrent requests overwrite each other and +// attribute events to the wrong IP, user agent or profile. +// Constructing it here also keeps importing this module side-effect free — a +// top-level `new OpenPanel()` runs the node SDK at import time, which breaks +// bundling the tRPC context into the Edge runtime. +function createClient() { + const client = new OpenPanel({ + clientId: env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID, + clientSecret: env.OPENPANEL_CLIENT_SECRET, + }); + client.setGlobalProperties({ + env: process.env.VERCEL_ENV || env.NODE_ENV || "localhost", + // app_version + }); return client; } @@ -38,7 +39,7 @@ export async function setupAnalytics(props: IdentifyProps) { return noop(); } - const op = getClient(); + const op = createClient(); if (props.location) { op.api.addHeader("x-client-ip", props.location);