From 1c56b4adba9c7b139d451e6d3fc7dec20b8dbfd6 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:22:55 -0700 Subject: [PATCH 1/2] feat(analytics): track MCP compatibility adoption Emit bounded MCP package/client telemetry, aggregate compatibility adoption into private analytics APIs and the operator dashboard, and keep the summary output aggregate-only. Add regression coverage for unsafe header rejection, MCP success/error telemetry, CLI telemetry headers, aggregate adoption summaries, API day clamps, and token/local-path no-leak invariants. --- apps/gittensory-ui/public/openapi.json | 30 ++++ .../src/routes/app.analytics.tsx | 115 +++++++++++++ packages/gittensory-mcp/bin/gittensory-mcp.js | 7 + src/api/routes.ts | 36 +++- src/db/repositories.ts | 58 ++++++- src/mcp/server.ts | 17 +- src/openapi/spec.ts | 2 +- src/services/client-telemetry.ts | 81 +++++++++ src/services/mcp-compatibility.ts | 36 ++++ src/types.ts | 17 ++ test/integration/api.test.ts | 158 +++++++++++++++++- test/unit/mcp-cli.test.ts | 27 +++ test/unit/mcp-compatibility.test.ts | 111 ++++++++++++ test/unit/mcp-server-telemetry.test.ts | 150 +++++++++++++++++ test/unit/product-usage-mcp-adoption.test.ts | 53 ++++++ 15 files changed, 879 insertions(+), 19 deletions(-) create mode 100644 src/services/client-telemetry.ts create mode 100644 test/unit/mcp-compatibility.test.ts create mode 100644 test/unit/mcp-server-telemetry.test.ts create mode 100644 test/unit/product-usage-mcp-adoption.test.ts diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 91a3fe545c..66ef961d4c 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -10850,6 +10850,36 @@ ] } }, + "/v1/app/analytics/mcp-compatibility": { + "get": { + "responses": { + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/app/commands/preview": { "post": { "responses": { diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx index 2e3e44d008..317fc6c16d 100644 --- a/apps/gittensory-ui/src/routes/app.analytics.tsx +++ b/apps/gittensory-ui/src/routes/app.analytics.tsx @@ -28,6 +28,21 @@ type OperatorDashboard = { githubActivatedRepos: number; }; }>; + mcpCompatibilityAdoption?: { + totalEvents: number; + activeActors: number; + staleEvents: number; + incompatibleEvents: number; + minimumSupportedVersion: string; + latestRecommendedVersion: string; + truncated: boolean; + byClientVersion: Array<{ key: string; count: number }>; + byProtocolVersion: Array<{ key: string; count: number }>; + byCompatibilityStatus: Array<{ + status: "current" | "stale" | "incompatible" | "unknown"; + count: number; + }>; + }; }; function ProductAnalytics() { @@ -114,6 +129,80 @@ function ProductAnalytics() { + {data.mcpCompatibilityAdoption ? ( +
+
+
+

+ MCP compatibility adoption +

+

+ Version distribution from redacted MCP product events. +

+
+ 0 + ? "degraded" + : data.mcpCompatibilityAdoption.staleEvents > 0 + ? "info" + : "ready" + } + > + {data.mcpCompatibilityAdoption.latestRecommendedVersion} + +
+
+ last 7 days} + /> + hashed actors} + /> + upgrade available} + /> + + min {data.mcpCompatibilityAdoption.minimumSupportedVersion} + + } + /> +
+
+ + + ({ + key: row.status, + count: row.count, + }))} + /> +
+ {data.mcpCompatibilityAdoption.truncated ? ( +

+ Displayed distribution is capped to keep dashboard reads bounded. +

+ ) : null} +
+ ) : null} + {data.usageRollups && data.usageRollups.length > 0 ? (
@@ -166,3 +255,29 @@ function ProductAnalytics() { ); } + +function CompatibilityList({ + title, + rows, +}: { + title: string; + rows: Array<{ key: string; count: number }>; +}) { + return ( +
+
{title}
+
+ {rows.length > 0 ? ( + rows.slice(0, 5).map((row) => ( +
+ {row.key} + {row.count} +
+ )) + ) : ( +
No events
+ )} +
+
+ ); +} diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 774b77be28..6ed1faa3d7 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -273,6 +273,10 @@ server.registerTool( } return toolResult("Gittensory local MCP status.", { apiUrl, + package: { + name: packageName, + version: packageVersion, + }, hasToken: Boolean(getApiToken()), authLogin: config.session?.login ?? null, sessionExpiresAt: config.session?.expiresAt ?? null, @@ -1203,6 +1207,9 @@ async function apiFetch(path, init, options = {}) { ...(token && options.auth !== false ? { authorization: `Bearer ${token}` } : {}), "content-type": "application/json", accept: "application/json", + "x-gittensory-mcp-package": packageName, + "x-gittensory-mcp-version": packageVersion, + "x-gittensory-mcp-client": "gittensory-mcp-cli", }, }).finally(() => clearTimeout(timeout)); const text = await response.text(); diff --git a/src/api/routes.ts b/src/api/routes.ts index af0164d983..79dcf2b17c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -73,6 +73,7 @@ import { persistSignalSnapshot, recordProductUsageEvent, rollupProductUsageDaily, + summarizeMcpCompatibilityAdoption, summarizeProductUsageEvents, upsertDigestSubscription, upsertBounty, @@ -106,6 +107,7 @@ import { preflightBranchWithAgent, startAgentRun, } from "../services/agent-orchestrator"; +import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { buildAndPersistContributorDecisionPack, loadContributorDecisionPackForServing, @@ -186,6 +188,7 @@ async function recordRouteProductUsage( metadata?: Record | null | undefined; }, ): Promise { + const telemetry = buildMcpClientTelemetry(c.req.raw.headers, { requireGittensoryHeader: true }); await recordProductUsageEvent(c.env, { surface: event.surface, eventName: event.eventName, @@ -196,9 +199,9 @@ async function recordRouteProductUsage( targetKey: event.targetKey, outcome: event.outcome, latencyMs: event.latencyMs, - clientName: event.clientName, - clientVersion: event.clientVersion, - metadata: event.metadata, + clientName: event.clientName ?? telemetry?.clientName, + clientVersion: event.clientVersion ?? telemetry?.clientVersion, + metadata: telemetry ? Object.assign({}, event.metadata, telemetry.metadata) : event.metadata, }).catch(() => undefined); } @@ -755,7 +758,21 @@ export function createApp() { const forbidden = await requireAppRole(c, ["operator"]); if (forbidden) return forbidden; const usageSince = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const [repositories, installations, health, registry, scoring, upstreamDrift, activeSessions, digestSubscriptions, rateLimits, usageSummary, usageRollups, usageRollupStatus] = await Promise.all([ + const [ + repositories, + installations, + health, + registry, + scoring, + upstreamDrift, + activeSessions, + digestSubscriptions, + rateLimits, + usageSummary, + usageRollups, + usageRollupStatus, + mcpCompatibilityAdoption, + ] = await Promise.all([ listRepositories(c.env), listInstallations(c.env), listInstallationHealth(c.env), @@ -768,6 +785,7 @@ export function createApp() { summarizeProductUsageEvents(c.env, usageSince), listProductUsageDailyRollups(c.env, { limit: 14 }), getProductUsageRollupStatus(c.env), + summarizeMcpCompatibilityAdoption(c.env, usageSince), ]); const installedRepos = repositories.filter((repo) => repo.isInstalled).length; const registeredRepos = repositories.filter((repo) => repo.isRegistered).length; @@ -781,6 +799,7 @@ export function createApp() { { label: "Product events", value: String(usageSummary.totalEvents), delta: "last 7 days" }, { label: "Active users", value: String(usageSummary.activeActors), delta: "hashed, last 7 days" }, { label: "Activation rollups", value: usageRollupStatus.status, delta: usageRollupStatus.latestRollupDay ?? "not generated" }, + { label: "MCP stale clients", value: String(mcpCompatibilityAdoption.staleEvents + mcpCompatibilityAdoption.incompatibleEvents), delta: `${mcpCompatibilityAdoption.totalEvents} MCP event(s)` }, { label: "Install issues", value: String(health.filter((record) => record.status !== "healthy").length), delta: "current health cache" }, { label: "Rate-limit events", value: String(rateLimits.length), delta: "latest observations" }, ], @@ -793,12 +812,21 @@ export function createApp() { usageSummary, usageRollups, usageRollupStatus, + mcpCompatibilityAdoption, registry, scoringModel: scoring, upstreamDrift, }); }); + app.get("/v1/app/analytics/mcp-compatibility", async (c) => { + const forbidden = await requireAppRole(c, ["operator"]); + if (forbidden) return forbidden; + const days = Math.max(1, Math.min(90, Number(c.req.query("days") ?? 7) || 7)); + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + return c.json({ generatedAt: nowIso(), days, adoption: await summarizeMcpCompatibilityAdoption(c.env, since) }); + }); + app.get("/v1/app/analytics/daily-rollups", async (c) => { const forbidden = await requireAppRole(c, ["operator"]); if (forbidden) return forbidden; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 1ea9f9c7e7..1d73cca7c3 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, gte, not, sql } from "drizzle-orm"; +import { and, desc, eq, gte, not, or, sql } from "drizzle-orm"; import { getDb } from "./client"; import { advisories, @@ -80,6 +80,7 @@ import type { IssueRecord, IssueQualityReportRecord, JsonValue, + McpCompatibilityAdoptionSummary, ProductUsageActivationFunnel, ProductUsageDailyRollupRecord, ProductUsageDailyRollupStatus, @@ -115,6 +116,7 @@ import type { UpstreamSourceStatus, } from "../types"; import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api"; +import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../services/mcp-compatibility"; import { sha256Hex } from "../utils/crypto"; import { jsonString, nowIso, parseJson, repoParts } from "../utils/json"; @@ -1078,6 +1080,45 @@ export async function summarizeProductUsageEvents(env: Env, sinceIso?: string): }; } +export async function summarizeMcpCompatibilityAdoption( + env: Env, + sinceIso?: string, + options: { limit?: number } = {}, +): Promise { + const db = getDb(env.DB); + const limit = Math.max(1, Math.min(MCP_COMPATIBILITY_ADOPTION_SCAN_LIMIT, Math.round(options.limit ?? MCP_COMPATIBILITY_ADOPTION_SCAN_LIMIT))); + const mcpClientWhere = or(eq(productUsageEvents.surface, "mcp"), eq(productUsageEvents.clientName, "gittensory-mcp"), eq(productUsageEvents.clientName, "gittensory-mcp-cli")); + const baseWhere = sinceIso ? and(mcpClientWhere, gte(productUsageEvents.occurredAt, sinceIso)) : mcpClientWhere; + const [totalRow] = await db.select({ count: sql`count(*)` }).from(productUsageEvents).where(baseWhere); + const [activeActorRow] = await db + .select({ count: sql`count(distinct ${productUsageEvents.actorHash})` }) + .from(productUsageEvents) + .where(and(baseWhere, sql`${productUsageEvents.actorHash} is not null`)); + const [activeSessionRow] = await db + .select({ count: sql`count(distinct ${productUsageEvents.sessionHash})` }) + .from(productUsageEvents) + .where(and(baseWhere, sql`${productUsageEvents.sessionHash} is not null`)); + const rows = await db.select().from(productUsageEvents).where(baseWhere).orderBy(desc(productUsageEvents.occurredAt)).limit(limit + 1); + const events = rows.slice(0, limit).map(toProductUsageEventRecord); + const compatibilityStatuses = events.map(mcpCompatibilityStatusForEvent); + return { + since: sinceIso, + totalEvents: Number(totalRow?.count ?? 0), + activeActors: Number(activeActorRow?.count ?? 0), + activeSessions: Number(activeSessionRow?.count ?? 0), + scannedEvents: events.length, + scanLimit: limit, + truncated: rows.length > limit || Number(totalRow?.count ?? 0) > limit, + minimumSupportedVersion: MINIMUM_SUPPORTED_MCP_VERSION, + latestRecommendedVersion: LATEST_RECOMMENDED_MCP_VERSION, + staleEvents: compatibilityStatuses.filter((status) => status === "stale").length, + incompatibleEvents: compatibilityStatuses.filter((status) => status === "incompatible").length, + byClientVersion: countProductUsageDimensions(events.map(mcpClientVersionForEvent)), + byProtocolVersion: countProductUsageDimensions(events.map((event) => productUsageMetadataString(event, "protocolVersion") ?? "unknown")), + byCompatibilityStatus: countProductUsageDimensions(compatibilityStatuses).map(({ key, count }) => ({ status: normalizeMcpCompatibilityStatus(key), count })), + }; +} + export async function rollupProductUsageDaily( env: Env, options: { day?: string; days?: number; nowIso?: string } = {}, @@ -3094,6 +3135,20 @@ function isProductUsageUsefulMaintainerEvent(event: ProductUsageEventRecord): bo return event.eventName === "agent_command_replied" && productUsageMetadataString(event, "actorKind") === "maintainer" && event.outcome === "completed"; } +function mcpClientVersionForEvent(event: ProductUsageEventRecord): string { + return event.clientVersion ?? productUsageMetadataString(event, "packageVersion") ?? "unknown"; +} + +function mcpCompatibilityStatusForEvent(event: ProductUsageEventRecord): "current" | "stale" | "incompatible" | "unknown" { + const metadataStatus = normalizeMcpCompatibilityStatus(productUsageMetadataString(event, "compatibilityStatus")); + if (metadataStatus !== "unknown") return metadataStatus; + return classifyMcpClientVersion(mcpClientVersionForEvent(event)); +} + +function normalizeMcpCompatibilityStatus(value: unknown): "current" | "stale" | "incompatible" | "unknown" { + return value === "current" || value === "stale" || value === "incompatible" ? value : "unknown"; +} + function productUsageMetadataString(event: ProductUsageEventRecord, key: string): string | null { const value = event.metadata[key]; return typeof value === "string" && value.trim() ? value.trim() : null; @@ -3173,6 +3228,7 @@ const PRODUCT_USAGE_METADATA_MAX_ARRAY_ITEMS = 20; const PRODUCT_USAGE_METADATA_MAX_KEY_CHARS = 64; const PRODUCT_USAGE_METADATA_MAX_STRING_CHARS = 200; const PRODUCT_USAGE_ROLLUP_EVENT_SCAN_LIMIT = 5000; +const MCP_COMPATIBILITY_ADOPTION_SCAN_LIMIT = 5000; const PRODUCT_USAGE_USEFUL_ACTION_EVENTS = new Set([ "command_previewed", "pull_context_viewed", diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d67fdae423..eb43e92818 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -42,6 +42,7 @@ import { import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../services/decision-pack"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; +import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { buildBountyAdvisory, buildCollisionReport, @@ -243,7 +244,8 @@ export async function handleMcpRequest(c: AppContext): Promise { const identity = await authenticateMcpRequest(c); if (!identity) return c.json({ error: "unauthorized" }, 401); - const usageMetadata = await describeMcpUsageRequest(c.req.raw); + const telemetry = buildMcpClientTelemetry(c.req.raw.headers, { defaultClientName: "mcp" })!; + const usageMetadata = await describeMcpUsageRequest(c.req.raw, telemetry.metadata); const startedAt = Date.now(); const server = new GittensoryMcp(c.env, identity).createServer(); try { @@ -256,8 +258,8 @@ export async function handleMcpRequest(c: AppContext): Promise { sessionId: identity.kind === "session" ? identity.session.id : undefined, outcome: response.status >= 400 ? "error" : "success", latencyMs: Date.now() - startedAt, - clientName: "mcp", - clientVersion: c.req.header("mcp-protocol-version"), + clientName: telemetry.clientName, + clientVersion: telemetry.clientVersion, metadata: usageMetadata, }).catch(() => undefined); return response; @@ -270,17 +272,17 @@ export async function handleMcpRequest(c: AppContext): Promise { sessionId: identity.kind === "session" ? identity.session.id : undefined, outcome: "error", latencyMs: Date.now() - startedAt, - clientName: "mcp", - clientVersion: c.req.header("mcp-protocol-version"), + clientName: telemetry.clientName, + clientVersion: telemetry.clientVersion, metadata: usageMetadata, }).catch(() => undefined); throw error; } } -async function describeMcpUsageRequest(request: Request): Promise> { +async function describeMcpUsageRequest(request: Request, telemetryMetadata: Record | undefined): Promise> { const body = await request.clone().json().catch(() => null); - if (!body || typeof body !== "object") return { transport: "http", method: request.method }; + if (!body || typeof body !== "object") return { transport: "http", method: request.method, ...telemetryMetadata }; const envelope = body as { method?: unknown; params?: { name?: unknown } }; const rpcMethod = typeof envelope.method === "string" ? envelope.method : undefined; const toolName = envelope.params && typeof envelope.params.name === "string" ? envelope.params.name : undefined; @@ -288,6 +290,7 @@ async function describeMcpUsageRequest(request: Request): Promise 80) return undefined; + return pattern.test(trimmed) ? trimmed : undefined; +} diff --git a/src/services/mcp-compatibility.ts b/src/services/mcp-compatibility.ts index 4501202bbf..a885259525 100644 --- a/src/services/mcp-compatibility.ts +++ b/src/services/mcp-compatibility.ts @@ -3,6 +3,8 @@ export const GITTENSORY_MCP_PACKAGE_NAME = "@jsonbored/gittensory-mcp"; export const MINIMUM_SUPPORTED_MCP_VERSION = "0.2.0"; export const LATEST_RECOMMENDED_MCP_VERSION = "0.3.0"; +export type McpCompatibilityStatus = "current" | "stale" | "incompatible" | "unknown"; + export type CompatibilityWarning = { code: string; message: string; @@ -51,3 +53,37 @@ export function buildMcpCompatibilityMetadata(generatedAt: string): McpCompatibi generatedAt, }; } + +export function classifyMcpClientVersion(version: string | null | undefined): McpCompatibilityStatus { + if (!version) return "unknown"; + const minimumComparison = compareMcpSemver(version, MINIMUM_SUPPORTED_MCP_VERSION); + if (minimumComparison === null) return "unknown"; + if (minimumComparison < 0) return "incompatible"; + const latestComparison = compareMcpSemver(version, LATEST_RECOMMENDED_MCP_VERSION)!; + return latestComparison < 0 ? "stale" : "current"; +} + +function parseSemver(version: string) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim()); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + }; +} + +export function compareMcpSemver(leftVersion: string, rightVersion: string): number | null { + const left = parseSemver(leftVersion); + const right = parseSemver(rightVersion); + if (!left || !right) return null; + for (const part of ["major", "minor", "patch"] as const) { + if (left[part] !== right[part]) return left[part] < right[part] ? -1 : 1; + } + if (left.prerelease === right.prerelease) return 0; + if (!left.prerelease) return 1; + if (!right.prerelease) return -1; + const prereleaseComparison = left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true, sensitivity: "base" }); + return prereleaseComparison === 0 ? 0 : prereleaseComparison < 0 ? -1 : 1; +} diff --git a/src/types.ts b/src/types.ts index c47bf2c88b..8ac5daf8a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -898,6 +898,23 @@ export type ProductUsageSummary = { byEvent: Array<{ eventName: string; count: number }>; }; +export type McpCompatibilityAdoptionSummary = { + since?: string | null | undefined; + totalEvents: number; + activeActors: number; + activeSessions: number; + scannedEvents: number; + scanLimit: number; + truncated: boolean; + minimumSupportedVersion: string; + latestRecommendedVersion: string; + staleEvents: number; + incompatibleEvents: number; + byClientVersion: ProductUsageDimensionCount[]; + byProtocolVersion: ProductUsageDimensionCount[]; + byCompatibilityStatus: Array<{ status: "current" | "stale" | "incompatible" | "unknown"; count: number }>; +}; + export type ProductUsageDailyRollupStatus = "complete" | "partial" | "incomplete"; export type ProductUsageDimensionCount = { diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 4be42dc46a..b23acde196 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -12,6 +12,7 @@ import { upsertRecentMergedPullRequest, persistRepoGithubTotalsSnapshot, persistSignalSnapshot, + recordProductUsageEvent, recordGitHubRateLimitObservation, listProductUsageEvents, listLatestSignalSnapshotsByTarget, @@ -1005,7 +1006,7 @@ describe("api routes", () => { it("serves live app dashboards, digest subscriptions, commands, and extension context", async () => { const app = createApp(); - const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "oktofeesh1,other" }); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "oktofeesh1,other", PRODUCT_USAGE_HASH_SALT: "usage-adoption-test-salt" }); await seedSignalData(env); stubOktofeeshFetch(); @@ -1096,6 +1097,7 @@ describe("api routes", () => { await expect(unknownOverview.json()).resolves.toMatchObject({ roleSummary: { roles: [], onboarding: { status: "needs_setup" } } }); expect((await app.request("/v1/app/operator-dashboard", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); expect((await app.request("/v1/app/analytics/daily-rollups", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); + expect((await app.request("/v1/app/analytics/mcp-compatibility", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); expect((await app.request("/v1/contributors/new-user/decision-pack", { headers: unknownHeaders }, unknownEnv)).status).toBe(403); expect((await app.request("/v1/auth/extension/session", { method: "POST", headers: unknownHeaders }, unknownEnv)).status).toBe(403); @@ -1121,6 +1123,7 @@ describe("api routes", () => { expect((await app.request("/v1/app/maintainer-dashboard", { headers: ownerHeaders }, ownerEnv)).status).toBe(200); expect((await app.request("/v1/app/operator-dashboard", { headers: ownerHeaders }, ownerEnv)).status).toBe(403); expect((await app.request("/v1/app/analytics/daily-rollups", { headers: ownerHeaders }, ownerEnv)).status).toBe(403); + expect((await app.request("/v1/app/analytics/mcp-compatibility", { headers: ownerHeaders }, ownerEnv)).status).toBe(403); const ownerExtensionSession = await app.request("/v1/auth/extension/session", { method: "POST", headers: ownerHeaders }, ownerEnv); expect(ownerExtensionSession.status).toBe(201); const ownerExtensionSessionBody = (await ownerExtensionSession.json()) as { token: string; login: string; scopes: string[] }; @@ -1479,10 +1482,25 @@ describe("api routes", () => { env, ); expect(queuedIssueAgentRun.status).toBe(202); + const overviewWithRuns = await app.request("/v1/app/overview", { headers: cookieHeaders }, env); + expect(overviewWithRuns.status).toBe(200); + await expect(overviewWithRuns.json()).resolves.toMatchObject({ + metrics: expect.arrayContaining([expect.objectContaining({ label: "Agent runs", total: 3 })]), + recentRuns: expect.arrayContaining([expect.objectContaining({ run: expect.objectContaining({ actorLogin: "oktofeesh1" }) })]), + }); const localAnalysis = await app.request( "/v1/local/branch-analysis", - { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ login: "oktofeesh1", repoFullName: "entrius/allways-ui", branchName: "usage-spine" }) }, + { + method: "POST", + headers: { + ...apiHeaders(env), + "x-gittensory-mcp-package": "@jsonbored/gittensory-mcp", + "x-gittensory-mcp-version": "0.3.0", + "x-gittensory-mcp-client": "gittensory-mcp-cli", + }, + body: JSON.stringify({ login: "oktofeesh1", repoFullName: "entrius/allways-ui", branchName: "usage-spine" }), + }, env, ); expect(localAnalysis.status).toBe(200); @@ -1605,6 +1623,51 @@ describe("api routes", () => { expect(revokedExtensionContext.status).toBe(401); await expect(revokedExtensionContext.json()).resolves.toMatchObject({ error: "unauthorized" }); + await recordProductUsageEvent(env, { + surface: "mcp", + eventName: "mcp_tool_called", + actor: "mcp-user", + sessionId: "mcp-session", + outcome: "success", + clientName: "gittensory-mcp", + clientVersion: "0.2.1", + metadata: { + toolName: "gittensory_local_status", + protocolVersion: "2025-03-26", + compatibilityStatus: "stale", + token: "github_pat_secret", + localPath: "/Users/example/private-repo", + }, + occurredAt: "2026-05-28T00:00:00.000Z", + }); + await recordProductUsageEvent(env, { + surface: "mcp", + eventName: "mcp_request", + actor: "old-mcp-user", + sessionId: "old-mcp-session", + outcome: "success", + clientName: "gittensory-mcp", + clientVersion: "0.1.0", + metadata: { + protocolVersion: "2024-11-05", + compatibilityStatus: "incompatible", + }, + occurredAt: "2026-05-28T00:00:00.000Z", + }); + await recordProductUsageEvent(env, { + surface: "mcp", + eventName: "mcp_request", + actor: "current-mcp-user", + sessionId: "current-mcp-session", + outcome: "success", + clientName: "gittensory-mcp", + clientVersion: "0.3.0", + metadata: { + protocolVersion: "2025-03-26", + }, + occurredAt: "2026-05-28T00:00:00.000Z", + }); + const productUsageEvents = await listProductUsageEvents(env, { limit: 20 }); expect(productUsageEvents).toEqual( expect.arrayContaining([ @@ -1612,9 +1675,10 @@ describe("api routes", () => { expect.objectContaining({ surface: "control_panel", eventName: "digest_subscription_stored", outcome: "success" }), expect.objectContaining({ surface: "browser_extension", eventName: "extension_session_created", outcome: "success" }), expect.objectContaining({ surface: "browser_extension", eventName: "pull_context_viewed", outcome: "success" }), + expect.objectContaining({ surface: "mcp", eventName: "mcp_tool_called", clientVersion: "0.2.1", metadata: expect.objectContaining({ compatibilityStatus: "stale" }) }), ]), ); - expect(JSON.stringify(productUsageEvents)).not.toMatch(/oktofeesh1|operator@example.com|gittensory_session|\/Users|github_pat|ghp_|source code|raw trust|wallet|hotkey/i); + expect(JSON.stringify(productUsageEvents)).not.toMatch(/oktofeesh1|operator@example.com|gittensory_session|\/Users|github_pat|ghp_|source code|raw trust|wallet|hotkey|private-repo/i); const usageRollupRun = await app.request( "/v1/internal/jobs/rollup-product-usage/run", @@ -1647,17 +1711,70 @@ describe("api routes", () => { const usageOperator = await app.request("/v1/app/operator-dashboard", { headers: apiHeaders(env) }, env); expect(usageOperator.status).toBe(200); - const usageOperatorBody = (await usageOperator.json()) as { metrics: Array<{ label: string; value: string }>; usageSummary: { totalEvents: number }; usageRollups: Array<{ day: string }>; usageRollupStatus: { status: string } }; + const usageOperatorBody = (await usageOperator.json()) as { + metrics: Array<{ label: string; value: string }>; + usageSummary: { totalEvents: number }; + usageRollups: Array<{ day: string }>; + usageRollupStatus: { status: string }; + mcpCompatibilityAdoption: { + totalEvents: number; + activeActors: number; + staleEvents: number; + incompatibleEvents: number; + byClientVersion: Array<{ key: string; count: number }>; + byProtocolVersion: Array<{ key: string; count: number }>; + byCompatibilityStatus: Array<{ status: string; count: number }>; + }; + }; expect(usageOperatorBody.metrics).toEqual( expect.arrayContaining([ expect.objectContaining({ label: "Product events", value: String(productUsageEvents.length) }), expect.objectContaining({ label: "Active users" }), expect.objectContaining({ label: "Activation rollups", value: "partial" }), + expect.objectContaining({ label: "MCP stale clients", value: "2" }), ]), ); expect(usageOperatorBody.usageSummary.totalEvents).toBe(productUsageEvents.length); expect(usageOperatorBody.usageRollups).toEqual([expect.objectContaining({ day: "2026-05-28" })]); expect(usageOperatorBody.usageRollupStatus.status).toBe("partial"); + expect(usageOperatorBody.mcpCompatibilityAdoption).toMatchObject({ + totalEvents: 4, + activeActors: 4, + staleEvents: 1, + incompatibleEvents: 1, + byClientVersion: expect.arrayContaining([ + { key: "0.1.0", count: 1 }, + { key: "0.2.1", count: 1 }, + { key: "0.3.0", count: 2 }, + ]), + byProtocolVersion: expect.arrayContaining([ + { key: "2024-11-05", count: 1 }, + { key: "2025-03-26", count: 2 }, + ]), + byCompatibilityStatus: expect.arrayContaining([ + { status: "current", count: 2 }, + { status: "incompatible", count: 1 }, + { status: "stale", count: 1 }, + ]), + }); + + const mcpCompatibility = await app.request("/v1/app/analytics/mcp-compatibility?days=7", { headers: apiHeaders(env) }, env); + expect(mcpCompatibility.status).toBe(200); + const mcpCompatibilityBody = await mcpCompatibility.json(); + expect(mcpCompatibilityBody).toMatchObject({ + adoption: expect.objectContaining({ + minimumSupportedVersion: "0.2.0", + latestRecommendedVersion: "0.3.0", + staleEvents: 1, + incompatibleEvents: 1, + totalEvents: 4, + }), + }); + expect(JSON.stringify(mcpCompatibilityBody)).not.toMatch(/github_pat|\/Users|private-repo|mcp-user|old-mcp-user|current-mcp-user/i); + await expect((await app.request("/v1/app/analytics/mcp-compatibility", { headers: apiHeaders(env) }, env)).json()).resolves.toMatchObject({ days: 7 }); + await expect((await app.request("/v1/app/analytics/mcp-compatibility?days=invalid", { headers: apiHeaders(env) }, env)).json()).resolves.toMatchObject({ days: 7 }); + await expect((await app.request("/v1/app/analytics/mcp-compatibility?days=999", { headers: apiHeaders(env) }, env)).json()).resolves.toMatchObject({ days: 90 }); + await expect((await app.request("/v1/app/analytics/mcp-compatibility?days=-5", { headers: apiHeaders(env) }, env)).json()).resolves.toMatchObject({ days: 1 }); }); it("covers live app auth, validation, and internal job queue edge routes", async () => { @@ -2871,6 +2988,18 @@ describe("api routes", () => { const missingBountyPayload = await mcpJson(missingBounty); expect(JSON.stringify(missingBountyPayload)).toMatch(/Bounty not found|error|isError/i); + const missingAgentRun = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ jsonrpc: "2.0", id: "missing-agent-run", method: "tools/call", params: { name: "gittensory_agent_get_run", arguments: { runId: "missing" } } }), + }, + env, + ); + expect(missingAgentRun.status).toBe(200); + expect(JSON.stringify(await mcpJson(missingAgentRun))).toMatch(/Agent run not found|error|isError/i); + const sessionEnv = createTestEnv({ ADMIN_GITHUB_LOGINS: "oktofeesh1" }); const { token: mcpSessionToken } = await createSessionForGitHubUser(sessionEnv, { login: "oktofeesh1", id: 12345 }); const forbiddenSessionTool = await app.request( @@ -2888,8 +3017,21 @@ describe("api routes", () => { const mcpUsageEvents = await listProductUsageEvents(env, { limit: 100 }); expect(mcpUsageEvents).toEqual( expect.arrayContaining([ - expect.objectContaining({ surface: "mcp", eventName: "mcp_request", outcome: "success" }), - expect.objectContaining({ surface: "mcp", eventName: "mcp_tool_called", outcome: "success", metadata: expect.objectContaining({ toolName: "gittensory_get_bounty_advisory" }) }), + expect.objectContaining({ surface: "mcp", eventName: "mcp_request", outcome: "success", clientName: "gittensory-mcp-cli", clientVersion: "0.3.0" }), + expect.objectContaining({ + surface: "mcp", + eventName: "mcp_tool_called", + outcome: "success", + clientName: "gittensory-mcp-cli", + clientVersion: "0.3.0", + metadata: expect.objectContaining({ + toolName: "gittensory_get_bounty_advisory", + protocolVersion: "2025-03-26", + compatibilityStatus: "current", + minimumSupportedVersion: "0.2.0", + latestRecommendedVersion: "0.3.0", + }), + }), ]), ); expect(JSON.stringify(mcpUsageEvents)).not.toMatch(/oktofeesh1|\/Users|github_pat|ghp_|source code|wallet|hotkey|raw trust/i); @@ -3350,6 +3492,10 @@ function mcpHeaders(env: Env, sessionId?: string): Record { authorization: `Bearer ${env.GITTENSORY_MCP_TOKEN}`, accept: "application/json, text/event-stream", "content-type": "application/json", + "mcp-protocol-version": "2025-03-26", + "x-gittensory-mcp-package": "@jsonbored/gittensory-mcp", + "x-gittensory-mcp-version": "0.3.0", + "x-gittensory-mcp-client": "gittensory-mcp-cli", ...(sessionId ? { "mcp-session-id": sessionId } : {}), }; } diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 9db886b644..eac6d661f7 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -323,6 +323,31 @@ describe("gittensory-mcp CLI", () => { expect(changelog.changelog).toContain("# Changelog"); }); + it("sends redacted MCP package telemetry headers to the API", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const requests: Array<{ url: string | undefined; headers: IncomingMessage["headers"] }> = []; + const url = await startFixtureServer({ onApiRequest: (request) => requests.push({ url: request.url, headers: request.headers }) }); + + await runAsync(["status", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }); + + const sessionRequest = requests.find((request) => request.url === "/v1/auth/session"); + expect(sessionRequest?.headers["x-gittensory-mcp-package"]).toBe("@jsonbored/gittensory-mcp"); + expect(sessionRequest?.headers["x-gittensory-mcp-version"]).toBe("0.3.0"); + expect(sessionRequest?.headers["x-gittensory-mcp-client"]).toBe("gittensory-mcp-cli"); + const telemetryHeaders = JSON.stringify({ + package: sessionRequest?.headers["x-gittensory-mcp-package"], + version: sessionRequest?.headers["x-gittensory-mcp-version"], + client: sessionRequest?.headers["x-gittensory-mcp-client"], + }); + expect(telemetryHeaders).not.toContain("session-token"); + expect(telemetryHeaders).not.toContain(tempDir); + }); + it("runs base-agent CLI commands against API fixtures", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const url = await startFixtureServer(); @@ -639,9 +664,11 @@ async function startFixtureServer( npmStatus?: number; packetMarkdown?: string; onPacketRequest?: (body: unknown) => void; + onApiRequest?: (request: IncomingMessage) => void; } = {}, ) { server = createServer(async (request, response) => { + options.onApiRequest?.(request); response.setHeader("content-type", "application/json"); if (request.url && request.url.includes("gittensory-mcp/latest")) { if (options.npmStatus && options.npmStatus >= 400) { diff --git a/test/unit/mcp-compatibility.test.ts b/test/unit/mcp-compatibility.test.ts new file mode 100644 index 0000000000..661b94e77d --- /dev/null +++ b/test/unit/mcp-compatibility.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { buildMcpClientTelemetry } from "../../src/services/client-telemetry"; +import { classifyMcpClientVersion, compareMcpSemver } from "../../src/services/mcp-compatibility"; + +describe("MCP compatibility telemetry", () => { + it("classifies local MCP package versions against the advertised support window", () => { + expect(classifyMcpClientVersion("0.1.9")).toBe("incompatible"); + expect(classifyMcpClientVersion("0.2.1")).toBe("stale"); + expect(classifyMcpClientVersion("0.3.0")).toBe("current"); + expect(classifyMcpClientVersion("not-a-version")).toBe("unknown"); + expect(classifyMcpClientVersion(undefined)).toBe("unknown"); + }); + + it("builds bounded telemetry from allowlisted MCP headers", () => { + const telemetry = buildMcpClientTelemetry( + new Headers({ + "x-gittensory-mcp-package": "@jsonbored/gittensory-mcp", + "x-gittensory-mcp-version": "0.2.1", + "x-gittensory-mcp-client": "gittensory-mcp-cli", + "mcp-protocol-version": "2025-03-26", + }), + { requireGittensoryHeader: true }, + ); + + expect(telemetry).toMatchObject({ + clientName: "gittensory-mcp-cli", + clientVersion: "0.2.1", + metadata: { + packageName: "@jsonbored/gittensory-mcp", + packageVersion: "0.2.1", + protocolVersion: "2025-03-26", + compatibilityStatus: "stale", + }, + }); + }); + + it("derives a safe client name from scoped package telemetry when no explicit client is sent", () => { + const telemetry = buildMcpClientTelemetry( + new Headers({ + "x-gittensory-mcp-package": "@example/custom-mcp", + "x-gittensory-mcp-version": "0.3.0", + }), + { requireGittensoryHeader: true }, + ); + + expect(telemetry).toMatchObject({ + clientName: "custom-mcp", + clientVersion: "0.3.0", + metadata: { + packageName: "@example/custom-mcp", + compatibilityStatus: "current", + }, + }); + }); + + it("uses the canonical package and default MCP client fallbacks without storing unsafe header data", () => { + const canonical = buildMcpClientTelemetry( + new Headers({ + "x-gittensory-mcp-package": "@jsonbored/gittensory-mcp", + "x-gittensory-mcp-version": "0.3.0", + }), + { requireGittensoryHeader: true }, + ); + expect(canonical).toMatchObject({ clientName: "gittensory-mcp", clientVersion: "0.3.0" }); + + const defaulted = buildMcpClientTelemetry(new Headers(), { defaultClientName: "mcp" }); + expect(defaulted).toMatchObject({ + clientName: "mcp", + metadata: { compatibilityStatus: "unknown" }, + }); + + const generic = buildMcpClientTelemetry(new Headers()); + expect(generic).toMatchObject({ clientName: "mcp" }); + }); + + it("drops token-like and local-path-like header values before analytics storage", () => { + const telemetry = buildMcpClientTelemetry( + new Headers({ + "x-gittensory-mcp-package": "/Users/example/private", + "x-gittensory-mcp-version": "github_pat_secretsecret", + "x-gittensory-mcp-client": "node /tmp/client.js", + "mcp-protocol-version": "Bearer secret-token-value", + }), + { requireGittensoryHeader: true }, + ); + + expect(telemetry).toBeNull(); + expect(JSON.stringify(telemetry)).not.toMatch(/Users|github_pat|Bearer|\/tmp|secret-token/i); + }); + + it("compares prerelease MCP versions with semver precedence", () => { + expect(compareMcpSemver("0.3.0", "0.3.0-rc.1")).toBe(1); + expect(compareMcpSemver("0.3.0-rc.1", "0.3.0")).toBe(-1); + expect(compareMcpSemver("0.3.0", "0.4.0")).toBe(-1); + expect(compareMcpSemver("0.4.0", "0.3.0")).toBe(1); + expect(compareMcpSemver("0.3.1", "0.3.0")).toBe(1); + expect(compareMcpSemver("0.3.0", "0.3.1")).toBe(-1); + expect(compareMcpSemver("0.3.0-rc.2", "0.3.0-rc.10")).toBe(-1); + expect(compareMcpSemver("0.3.0-rc.10", "0.3.0-rc.2")).toBe(1); + expect(compareMcpSemver("0.3.0-beta", "0.3.0-alpha")).toBe(1); + expect(compareMcpSemver("0.3.0-alpha", "0.3.0-beta")).toBe(-1); + expect(compareMcpSemver("0.3.0-1", "0.3.0-alpha")).toBe(-1); + expect(compareMcpSemver("0.3.0-alpha", "0.3.0-1")).toBe(1); + expect(compareMcpSemver("0.3.0-rc.1", "0.3.0-rc.1.1")).toBe(-1); + expect(compareMcpSemver("0.3.0-rc.1.1", "0.3.0-rc.1")).toBe(1); + expect(compareMcpSemver("0.3.0-rc.1", "0.3.0-rc.1")).toBe(0); + expect(compareMcpSemver("0.3.0-RC.1", "0.3.0-rc.1")).toBe(0); + expect(compareMcpSemver("v0.3.0", "0.3.0")).toBe(0); + expect(compareMcpSemver("bad", "0.3.0")).toBeNull(); + }); +}); diff --git a/test/unit/mcp-server-telemetry.test.ts b/test/unit/mcp-server-telemetry.test.ts new file mode 100644 index 0000000000..cfeb38ec52 --- /dev/null +++ b/test/unit/mcp-server-telemetry.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { listProductUsageEvents } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +describe("MCP server telemetry", () => { + afterEach(() => { + vi.doUnmock("agents/mcp"); + vi.resetModules(); + }); + + it("records sanitized error telemetry when the MCP transport handler throws", async () => { + vi.resetModules(); + vi.doMock("agents/mcp", () => ({ + createMcpHandler: () => () => { + throw new Error("transport_failed"); + }, + })); + const { handleMcpRequest } = await import("../../src/mcp/server"); + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "mcp-error-test-salt" }); + const request = new Request("https://api.test/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${env.GITTENSORY_MCP_TOKEN}`, + "content-type": "application/json", + "x-gittensory-mcp-package": "@jsonbored/gittensory-mcp", + "x-gittensory-mcp-version": "0.3.0", + "x-gittensory-mcp-client": "gittensory-mcp-cli", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "error-telemetry", + method: "tools/call", + params: { name: "gittensory_local_status" }, + }), + }); + + await expect( + handleMcpRequest({ + env, + executionCtx: { waitUntil() {}, passThroughOnException() {} }, + req: { + method: "POST", + raw: request, + header: (name: string) => request.headers.get(name) ?? undefined, + }, + json: (body: unknown, status?: number) => Response.json(body, status === undefined ? undefined : { status }), + } as never), + ).rejects.toThrow("transport_failed"); + + await expect(listProductUsageEvents(env, { limit: 5 })).resolves.toEqual([ + expect.objectContaining({ + surface: "mcp", + eventName: "mcp_tool_called", + outcome: "error", + clientName: "gittensory-mcp-cli", + clientVersion: "0.3.0", + metadata: expect.objectContaining({ + toolName: "gittensory_local_status", + compatibilityStatus: "current", + }), + }), + ]); + }); + + it("falls back when Hono does not expose an execution context", async () => { + vi.resetModules(); + vi.doMock("agents/mcp", () => ({ + createMcpHandler: () => () => Response.json({ ok: true }), + })); + const { handleMcpRequest } = await import("../../src/mcp/server"); + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "mcp-success-test-salt" }); + const request = new Request("https://api.test/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${env.GITTENSORY_MCP_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: "ping", method: "ping" }), + }); + const context = { + env, + req: { + method: "POST", + raw: request, + header: (name: string) => request.headers.get(name) ?? undefined, + }, + json: (body: unknown, status?: number) => Response.json(body, status === undefined ? undefined : { status }), + }; + Object.defineProperty(context, "executionCtx", { + get() { + throw new Error("execution context unavailable"); + }, + }); + + await expect(handleMcpRequest(context as never)).resolves.toMatchObject({ status: 200 }); + await expect(listProductUsageEvents(env, { limit: 5 })).resolves.toEqual([ + expect.objectContaining({ + surface: "mcp", + eventName: "mcp_request", + outcome: "success", + clientName: "mcp", + metadata: expect.objectContaining({ rpcMethod: "ping", compatibilityStatus: "unknown" }), + }), + ]); + }); + + it("records session-scoped MCP request errors without a tool name", async () => { + vi.resetModules(); + vi.doMock("agents/mcp", () => ({ + createMcpHandler: () => () => { + throw new Error("request_failed"); + }, + })); + const { handleMcpRequest } = await import("../../src/mcp/server"); + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "mcp-session-error-salt", ADMIN_GITHUB_LOGINS: "oktofeesh1" }); + const { token } = await createSessionForGitHubUser(env, { login: "oktofeesh1", id: 12345 }); + const request = new Request("https://api.test/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: "error-request", method: "ping" }), + }); + + await expect( + handleMcpRequest({ + env, + executionCtx: { waitUntil() {}, passThroughOnException() {} }, + req: { + method: "POST", + raw: request, + header: (name: string) => request.headers.get(name) ?? undefined, + }, + json: (body: unknown, status?: number) => Response.json(body, status === undefined ? undefined : { status }), + } as never), + ).rejects.toThrow("request_failed"); + + await expect(listProductUsageEvents(env, { limit: 5 })).resolves.toEqual([ + expect.objectContaining({ + surface: "mcp", + eventName: "mcp_request", + outcome: "error", + sessionHash: expect.any(String), + metadata: expect.objectContaining({ rpcMethod: "ping" }), + }), + ]); + }); +}); diff --git a/test/unit/product-usage-mcp-adoption.test.ts b/test/unit/product-usage-mcp-adoption.test.ts new file mode 100644 index 0000000000..fc9da4955e --- /dev/null +++ b/test/unit/product-usage-mcp-adoption.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { recordProductUsageEvent, summarizeMcpCompatibilityAdoption } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +describe("MCP compatibility adoption summaries", () => { + it("aggregates MCP-surfaced and CLI-backed API usage without exposing identities", async () => { + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "mcp-adoption-test-salt" }); + await recordProductUsageEvent(env, { + surface: "api", + eventName: "local_branch_analysis_completed", + actor: "oktofeesh1", + sessionId: "cli-session", + clientName: "gittensory-mcp-cli", + metadata: { + packageVersion: "0.2.1", + protocolVersion: "2025-03-26", + }, + occurredAt: "2026-05-28T00:02:00.000Z", + }); + await recordProductUsageEvent(env, { + surface: "mcp", + eventName: "mcp_request", + actor: "other-user", + sessionId: "mcp-session", + metadata: {}, + occurredAt: "2026-05-28T00:01:00.000Z", + }); + + const summary = await summarizeMcpCompatibilityAdoption(env); + expect(summary).toMatchObject({ + totalEvents: 2, + activeActors: 2, + activeSessions: 2, + truncated: false, + byClientVersion: expect.arrayContaining([ + { key: "0.2.1", count: 1 }, + { key: "unknown", count: 1 }, + ]), + byCompatibilityStatus: expect.arrayContaining([ + { status: "stale", count: 1 }, + { status: "unknown", count: 1 }, + ]), + }); + expect(JSON.stringify(summary)).not.toMatch(/oktofeesh1|other-user|cli-session|mcp-session/i); + + const capped = await summarizeMcpCompatibilityAdoption(env, undefined, { limit: 1 }); + expect(capped).toMatchObject({ + totalEvents: 2, + scannedEvents: 1, + truncated: true, + }); + }); +}); From 7dd641aa5606351a067c118db6ebb8021cc5aee9 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:29:03 -0700 Subject: [PATCH 2/2] fix(analytics): redact short telemetry actors Remove the short-name skip from product usage actor redaction and match actor tokens at segment boundaries so short logins are still removed without corrupting unrelated words. Add a regression test for two-character actor leakage across repo names, target keys, metadata values, and metadata keys. --- src/db/repositories.ts | 7 +++-- test/unit/product-usage.test.ts | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 1d73cca7c3..a4fe5c3470 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -2972,12 +2972,12 @@ function boundedProductUsageField(value: unknown, maxLength: number): string | n function buildProductUsageActorRedactor(actor: unknown): RegExp | null { const normalized = typeof actor === "string" ? actor.trim() : ""; - if (normalized.length < 4) return null; - return new RegExp(escapeRegExp(normalized), "gi"); + if (!normalized || normalized.length > PRODUCT_USAGE_ACTOR_REDACTION_MAX_CHARS) return null; + return new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(normalized)}(?=$|[^A-Za-z0-9])`, "gi"); } function redactProductUsageActor(value: string | null, actorRedactor: RegExp | null): string | null { - return value && actorRedactor ? value.replace(actorRedactor, "") : value; + return value && actorRedactor ? value.replace(actorRedactor, "$1") : value; } function escapeRegExp(value: string): string { @@ -3229,6 +3229,7 @@ const PRODUCT_USAGE_METADATA_MAX_KEY_CHARS = 64; const PRODUCT_USAGE_METADATA_MAX_STRING_CHARS = 200; const PRODUCT_USAGE_ROLLUP_EVENT_SCAN_LIMIT = 5000; const MCP_COMPATIBILITY_ADOPTION_SCAN_LIMIT = 5000; +const PRODUCT_USAGE_ACTOR_REDACTION_MAX_CHARS = 256; const PRODUCT_USAGE_USEFUL_ACTION_EVENTS = new Set([ "command_previewed", "pull_context_viewed", diff --git a/test/unit/product-usage.test.ts b/test/unit/product-usage.test.ts index 81606f02c5..62af9662f9 100644 --- a/test/unit/product-usage.test.ts +++ b/test/unit/product-usage.test.ts @@ -47,6 +47,57 @@ describe("product usage events", () => { expect(JSON.stringify(row)).not.toMatch(/Oktofeesh1|gts_session_secret/i); }); + it("redacts short actor names from persisted telemetry without corrupting unrelated words", async () => { + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "fixed-test-salt" }); + + await recordProductUsageEvent(env, { + surface: "api", + eventName: "local_branch_analysis_completed", + actor: "ab", + repoFullName: "ab/private-tool", + targetKey: "ab:private-tool#139", + metadata: { + viewer: "ab", + note: "for ab, but cabin stays readable", + "ab": "owner key redacted too", + }, + }); + + const [row] = await listProductUsageEvents(env); + expect(row).toBeDefined(); + if (!row) throw new Error("expected product usage event"); + expect(row.repoFullName).toBe("/private-tool"); + expect(row.targetKey).toBe(":private-tool#139"); + expect(row.metadata).toMatchObject({ + viewer: "", + note: "for , but cabin stays readable", + "": "owner key redacted too", + }); + expect(JSON.stringify(row)).not.toMatch(/"ab"|\bab\/|\bab:|for ab\b/i); + }); + + it("bounds actor redaction patterns while still covering long valid handles", async () => { + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "fixed-test-salt" }); + const actor = "a".repeat(200); + + await recordProductUsageEvent(env, { + surface: "api", + eventName: "local_branch_analysis_completed", + actor, + repoFullName: `${actor}/private-tool`, + targetKey: `${actor}:private-tool#139`, + metadata: { viewer: actor }, + }); + + const [row] = await listProductUsageEvents(env); + expect(row).toBeDefined(); + if (!row) throw new Error("expected product usage event"); + expect(row.repoFullName).toBe("/private-tool"); + expect(row.targetKey).toBe(":private-tool#139"); + expect(row.metadata).toMatchObject({ viewer: "" }); + expect(JSON.stringify(row)).not.toContain(actor); + }); + it("redacts sensitive metadata before it reaches D1", async () => { const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "fixed-test-salt" });