diff --git a/src/client/features/ai-mcp/SetupControls.tsx b/src/client/features/ai-mcp/SetupControls.tsx index 87542f4..2040ff7 100644 --- a/src/client/features/ai-mcp/SetupControls.tsx +++ b/src/client/features/ai-mcp/SetupControls.tsx @@ -57,7 +57,13 @@ export function Collapsible({ ); } -export function CodeBlock({ code }: { code: string }) { +export function CodeBlock({ + code, + onCopy, +}: { + code: string; + onCopy?: () => void; +}) { return (
@@ -68,6 +74,7 @@ export function CodeBlock({ code }: { code: string }) {
value={code}
successMessage="Copied to clipboard"
iconOnly
+ onCopy={onCopy}
/>
MCP server URL
-
{mcpUrl}
@@ -89,6 +94,11 @@ function AiPage() {
+ captureClientEvent("mcp:setup_command_copy", {
+ agent: "claude-code",
+ })
+ }
/>
Approve the login when prompted.
@@ -141,7 +151,14 @@ function AiPage() {
Run this in your terminal:
-
+
+ captureClientEvent("mcp:setup_command_copy", {
+ agent: "codex",
+ })
+ }
+ />
Approve the login when prompted.
diff --git a/src/routes/_authenticated.oauth-consent.tsx b/src/routes/_authenticated.oauth-consent.tsx
index f05b1c4..7b6e43b 100644
--- a/src/routes/_authenticated.oauth-consent.tsx
+++ b/src/routes/_authenticated.oauth-consent.tsx
@@ -1,7 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { Check, Database, KeyRound, User } from "lucide-react";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useSession } from "@/lib/auth-client";
+import { captureClientEvent } from "@/client/lib/posthog";
export const Route = createFileRoute("/_authenticated/oauth-consent")({
component: OAuthConsentPage,
@@ -27,9 +28,16 @@ function OAuthConsentPage() {
const userEmail = session?.user?.email ?? null;
+ useEffect(() => {
+ captureClientEvent("mcp:consent_viewed");
+ }, []);
+
async function respond(accept: boolean) {
setError(null);
setIsSubmitting(true);
+ if (!accept) {
+ captureClientEvent("mcp:consent_denied");
+ }
const response = await fetch("/api/oauth/consent", {
method: "POST",
diff --git a/src/server/mcp/instrumentation.test.ts b/src/server/mcp/instrumentation.test.ts
index b1ac5a4..55003a1 100644
--- a/src/server/mcp/instrumentation.test.ts
+++ b/src/server/mcp/instrumentation.test.ts
@@ -1,11 +1,16 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
-import type { ToolExtra } from "@/server/mcp/context";
+import {
+ runWithMcpToolAuthContext,
+ type McpToolAuthContext,
+ type ToolExtra,
+} from "@/server/mcp/context";
import { AppError } from "@/server/lib/errors";
const mocks = vi.hoisted(() => ({
captureServerError: vi.fn(),
+ captureServerEvent: vi.fn(),
}));
// waitUntil runs the capture promise inline so assertions see the call.
@@ -15,6 +20,7 @@ vi.mock("cloudflare:workers", () => ({
vi.mock("@/server/lib/posthog", () => ({
captureServerError: mocks.captureServerError,
+ captureServerEvent: mocks.captureServerEvent,
}));
const toolExtra: ToolExtra = {
@@ -30,9 +36,21 @@ function okResult(structuredContent: Record): CallToolResult {
return { content: [{ type: "text", text: "ok" }], structuredContent };
}
+const authContext: McpToolAuthContext = {
+ userId: "user-1",
+ userEmail: "user@example.com",
+ organizationId: "org-1",
+ clientId: "client-1",
+ scopes: ["mcp"],
+ audience: "https://app.openseo.so/mcp",
+ subject: "user-1",
+ baseUrl: "https://app.openseo.so",
+};
+
describe("instrumentMcpToolHandler", () => {
beforeEach(() => {
mocks.captureServerError.mockReset();
+ mocks.captureServerEvent.mockReset();
});
it("passes a valid result through without reporting", async () => {
@@ -85,4 +103,71 @@ describe("instrumentMcpToolHandler", () => {
await expect(wrapped({}, toolExtra)).rejects.toThrow("NOT_FOUND");
expect(mocks.captureServerError).not.toHaveBeenCalled();
});
+
+ it("captures a usage event when auth context is present", async () => {
+ const { instrumentMcpToolHandler } = await import("./instrumentation");
+ const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
+ okResult({ items: [] }),
+ );
+
+ await runWithMcpToolAuthContext(authContext, () =>
+ wrapped({}, toolExtra),
+ );
+
+ expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
+ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
+ distinctId: "user-1",
+ event: "mcp:tool_call",
+ organizationId: "org-1",
+ properties: {
+ tool: "demo",
+ success: true,
+ client_id: "client-1",
+ source: "mcp_client",
+ },
+ });
+ });
+
+ it("marks schema-rejected results as failed usage", async () => {
+ const { instrumentMcpToolHandler } = await import("./instrumentation");
+ const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
+ okResult({ items: "not-an-array" }),
+ );
+
+ await runWithMcpToolAuthContext(authContext, () =>
+ wrapped({}, toolExtra),
+ );
+
+ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
+ event: "mcp:tool_call",
+ properties: { success: false, error_code: "MCP_OUTPUT_VALIDATION" },
+ });
+ });
+
+ it("captures a failed usage event with the error code", async () => {
+ const { instrumentMcpToolHandler } = await import("./instrumentation");
+ const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
+ throw new AppError("NOT_FOUND");
+ });
+
+ await expect(
+ runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)),
+ ).rejects.toThrow("NOT_FOUND");
+
+ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
+ event: "mcp:tool_call",
+ properties: { success: false, error_code: "NOT_FOUND" },
+ });
+ });
+
+ it("skips the usage event when auth context is missing", async () => {
+ const { instrumentMcpToolHandler } = await import("./instrumentation");
+ const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
+ okResult({ items: [] }),
+ );
+
+ await wrapped({}, toolExtra);
+
+ expect(mocks.captureServerEvent).not.toHaveBeenCalled();
+ });
});
diff --git a/src/server/mcp/instrumentation.ts b/src/server/mcp/instrumentation.ts
index 470cea7..baadba0 100644
--- a/src/server/mcp/instrumentation.ts
+++ b/src/server/mcp/instrumentation.ts
@@ -8,15 +8,48 @@ import {
type ZodRawShapeCompat,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { asAppError } from "@/server/lib/errors";
-import { captureServerError } from "@/server/lib/posthog";
+import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
-import type { ToolExtra } from "@/server/mcp/context";
+import { getAuth, type ToolExtra } from "@/server/mcp/context";
type ToolHandler = (
args: TArgs,
extra: ToolExtra,
) => CallToolResult | Promise;
+/**
+ * Usage analytics for every MCP tool invocation. `clientId` distinguishes
+ * external MCP clients (OAuth) from the in-app agent (first-party auth, null
+ * clientId); self-hosted installs never report because captureServerEvent is
+ * gated to hosted mode. Analytics must never affect the tool call, so a
+ * missing auth context (e.g. in tests) is swallowed.
+ */
+function captureMcpToolCall(
+ toolName: string,
+ extra: ToolExtra,
+ outcome: { success: boolean; errorCode?: string },
+) {
+ try {
+ const auth = getAuth(extra);
+ waitUntil(
+ captureServerEvent({
+ distinctId: auth.userId,
+ event: "mcp:tool_call",
+ organizationId: auth.organizationId,
+ properties: {
+ tool: toolName,
+ success: outcome.success,
+ error_code: outcome.errorCode,
+ client_id: auth.clientId,
+ source: auth.clientId ? "mcp_client" : "in_app_agent",
+ },
+ }),
+ );
+ } catch {
+ // no auth context — skip analytics
+ }
+}
+
/**
* Wraps an MCP tool handler so failures reach PostHog. Unlike TanStack server
* functions (covered by errorHandlingMiddleware), the MCP route has no error
@@ -41,6 +74,9 @@ export function instrumentMcpToolHandler(
return async (args, extra) => {
try {
const result = await handler(args, extra);
+ // The SDK converts an output-schema mismatch into a client-visible
+ // JSON-RPC error, so count it as a failed call, not a success.
+ let outputValidationFailed = false;
if (
normalizedOutputSchema &&
!result.isError &&
@@ -51,6 +87,7 @@ export function instrumentMcpToolHandler(
result.structuredContent,
);
if (!validation.success) {
+ outputValidationFailed = true;
// getParseErrorMessage reports type-level mismatches (expected vs
// received *types*), so it carries no row data. Keep it that way:
// output schemas must not gain value-echoing refinements (enums on
@@ -67,9 +104,20 @@ export function instrumentMcpToolHandler(
);
}
}
+ captureMcpToolCall(
+ toolName,
+ extra,
+ outputValidationFailed
+ ? { success: false, errorCode: "MCP_OUTPUT_VALIDATION" }
+ : { success: !result.isError },
+ );
return result;
} catch (error) {
const appError = asAppError(error);
+ captureMcpToolCall(toolName, extra, {
+ success: false,
+ errorCode: appError?.code ?? "INTERNAL_ERROR",
+ });
if (shouldCaptureAppErrorCode(appError?.code)) {
console.error(`mcp.tool error (${toolName}):`, error);
waitUntil(
diff --git a/src/server/mcp/oauth-provider.ts b/src/server/mcp/oauth-provider.ts
index ad534d1..9c35fb3 100644
--- a/src/server/mcp/oauth-provider.ts
+++ b/src/server/mcp/oauth-provider.ts
@@ -1,3 +1,4 @@
+import { waitUntil } from "cloudflare:workers";
import {
OAuthProvider,
type AuthRequest,
@@ -12,6 +13,7 @@ import {
MCP_SCOPE,
} from "@/lib/oauth-resource";
import { asAppError } from "@/server/lib/errors";
+import { captureServerEvent } from "@/server/lib/posthog";
import {
createWorkersOAuthMcpProps,
MCP_ROUTE,
@@ -364,6 +366,18 @@ async function handleOAuthConsentResponse(
props,
});
+ waitUntil(
+ captureServerEvent({
+ distinctId: context.userId,
+ event: "mcp:authorize_success",
+ organizationId: context.organizationId,
+ properties: {
+ client_id: authRequest.clientId,
+ scopes: scopes.join(" "),
+ },
+ }),
+ );
+
return jsonResponse({ redirectTo });
}
diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts
index 3d2d8d6..2cdc9de 100644
--- a/src/server/mcp/server.ts
+++ b/src/server/mcp/server.ts
@@ -210,21 +210,37 @@ export function registerOpenSeoMcpTools(server: McpServer) {
server.registerTool(
runSiteAuditTool.name,
runSiteAuditTool.config,
- runSiteAuditTool.handler,
+ instrumentMcpToolHandler(
+ runSiteAuditTool.name,
+ runSiteAuditTool.config.outputSchema,
+ runSiteAuditTool.handler,
+ ),
);
server.registerTool(
getAuditStatusTool.name,
getAuditStatusTool.config,
- getAuditStatusTool.handler,
+ instrumentMcpToolHandler(
+ getAuditStatusTool.name,
+ getAuditStatusTool.config.outputSchema,
+ getAuditStatusTool.handler,
+ ),
);
server.registerTool(
getAuditIssuesTool.name,
getAuditIssuesTool.config,
- getAuditIssuesTool.handler,
+ instrumentMcpToolHandler(
+ getAuditIssuesTool.name,
+ getAuditIssuesTool.config.outputSchema,
+ getAuditIssuesTool.handler,
+ ),
);
server.registerTool(
getAuditPagesTool.name,
getAuditPagesTool.config,
- getAuditPagesTool.handler,
+ instrumentMcpToolHandler(
+ getAuditPagesTool.name,
+ getAuditPagesTool.config.outputSchema,
+ getAuditPagesTool.handler,
+ ),
);
}