@@ -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" });