Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/argent-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@argent/telemetry": "file:../telemetry",
"@argent/tools-client": "file:../argent-tools-client",
"@modelcontextprotocol/sdk": "^1.29.0"
},
Expand Down
21 changes: 20 additions & 1 deletion packages/argent-mcp/src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ToolMeta,
type ToolsServerPaths,
} from "@argent/tools-client";
import { canonicalizeAiClient, AI_CLIENT_NAME_PATTERN } from "@argent/telemetry";
import {
toMcpContent,
flowRunToMcpContent,
Expand Down Expand Up @@ -106,6 +107,24 @@ export async function startMcpServer(options: StartMcpServerOptions): Promise<vo
return AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {};
}

// Coarse identity of the AI tool driving this MCP server, forwarded to the
// tool-server (a separate process that owns tool telemetry) as request headers.
// The signal is the MCP handshake clientInfo.name; unrecognized tools are
// reported as `other` with a bounded free-form name. Never carries prompts,
// model output, or tool args.
function aiClientHeaders(): Record<string, string> {
const rawName = server.getClientVersion()?.name?.trim() || undefined;
const aiClient = canonicalizeAiClient(rawName);
if (aiClient) return { "X-Argent-AI-Client": aiClient };
if (rawName) {
return {
"X-Argent-AI-Client": "other",
...(AI_CLIENT_NAME_PATTERN.test(rawName) ? { "X-Argent-AI-Client-Name": rawName } : {}),
};
}
return {};
}

let reconnectPromise: Promise<void> | null = null;

async function reconnect(): Promise<void> {
Expand Down Expand Up @@ -162,7 +181,7 @@ export async function startMcpServer(options: StartMcpServerOptions): Promise<vo
const res = await fetchWithReconnect(() => `${TOOLS_URL}/tools/${name}`, reconnect, {
init: {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeader() },
headers: { "Content-Type": "application/json", ...authHeader(), ...aiClientHeaders() },
body: JSON.stringify(args ?? {}),
},
fetchTimeoutMs: meta?.longRunning ? null : FETCH_TIMEOUT_MS,
Expand Down
74 changes: 74 additions & 0 deletions packages/telemetry/src/ai-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Coarse identity of the AI coding tool driving the MCP server. We record only
// a canonical client slug (which tool) — never prompts, model output, or args.
//
// The single signal is the MCP `initialize` handshake `clientInfo.name` (ground
// truth of what is actually connecting), read at runtime via
// `Server.getClientVersion()`. Anything we can't map is reported as `other` with
// a sanitized free-form name.

export const AI_CLIENTS = [
"codex",
"claude_code",
"cursor",
"gemini",
"vscode",
"windsurf",
"zed",
"opencode",
"copilot",
"other",
] as const;

export type AiClient = (typeof AI_CLIENTS)[number];

/** Bounded free-form client name, used for the long tail of unrecognized tools. */
export const AI_CLIENT_NAME_PATTERN = /^[A-Za-z0-9 ._-]{1,80}$/;

export type AiTelemetryProps = {
ai_client?: AiClient;
/** Raw, sanitized clientInfo.name — only carried when `ai_client` is `other`. */
ai_client_name?: string;
};

// Runtime MCP `clientInfo.name` → canonical slug. Patterns are tested against the
// trimmed, lower-cased name. Each is verified against the tool's source; we match
// the CLIENT identity precisely so decoys are excluded
const RUNTIME_CLIENT_PATTERNS: ReadonlyArray<readonly [RegExp, AiClient]> = [
[/^codex-mcp-client\b/, "codex"],
[/^claude-code\b/, "claude_code"],
[/^cursor\b/, "cursor"],
[/^gemini-cli-mcp-client\b/, "gemini"],
[/^visual studio code\b/, "vscode"],
[/^code - oss\b/, "vscode"],
[/^windsurf\b/, "windsurf"],
[/^zed\b/, "zed"],
[/^opencode\b/, "opencode"],
[/^github-copilot-developer\b/, "copilot"],
];

/**
* Pick out only the AI-client telemetry keys from a wider metadata object,
* omitting any that are absent so events never carry `undefined` values. Shared
* by every emitter so the spread shape stays identical across call sites.
*/
export function aiTelemetryFromMeta(meta: AiTelemetryProps): AiTelemetryProps {
return {
...(meta.ai_client ? { ai_client: meta.ai_client } : {}),
...(meta.ai_client_name ? { ai_client_name: meta.ai_client_name } : {}),
};
}

/**
* Normalize a runtime MCP `clientInfo.name` to an {@link AiClient}. Returns
* `undefined` for anything unrecognized (callers may then fall back to `other` +
* a sanitized free-form name).
*/
export function canonicalizeAiClient(value: string | undefined | null): AiClient | undefined {
if (typeof value !== "string") return undefined;
const lower = value.trim().toLowerCase();
if (!lower) return undefined;
for (const [pattern, slug] of RUNTIME_CLIENT_PATTERNS) {
if (pattern.test(lower)) return slug;
}
return undefined;
}
7 changes: 4 additions & 3 deletions packages/telemetry/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// same surface at runtime.

import type { FailureSignal } from "@argent/registry";
import type { AiTelemetryProps } from "./ai-identity.js";

// Installation events

Expand Down Expand Up @@ -98,20 +99,20 @@ export interface InstallationCliUninstallCompleteProps extends FailureTelemetryP

// Tool usage events

export interface ToolInvokeProps {
export interface ToolInvokeProps extends AiTelemetryProps {
tool: string;
tool_invocation_id: string;
platform?: "ios" | "android";
}

export interface ToolCompleteProps {
export interface ToolCompleteProps extends AiTelemetryProps {
tool: string;
tool_invocation_id: string;
platform?: "ios" | "android";
duration_ms: number;
}

export interface ToolFailProps extends FailureTelemetryProps {
export interface ToolFailProps extends FailureTelemetryProps, AiTelemetryProps {
tool: string;
tool_invocation_id?: string;
platform?: "ios" | "android";
Expand Down
8 changes: 8 additions & 0 deletions packages/telemetry/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export { EVENT_NAMES } from "./events.js";
export { isDebugEnabled } from "./debug.js";
export { getConsentState } from "./consent.js";
export { getSessionId } from "./base-props.js";
export {
AI_CLIENTS,
AI_CLIENT_NAME_PATTERN,
canonicalizeAiClient,
aiTelemetryFromMeta,
type AiClient,
type AiTelemetryProps,
} from "./ai-identity.js";

const SHORT_FLUSH_TIMEOUT_MS = 1_500;

Expand Down
8 changes: 6 additions & 2 deletions packages/telemetry/src/registry-listener.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { FAILURE_CODES, getFailureSignalOrFallback, type Registry } from "@argent/registry";
import { track } from "./index.js";
import { aiTelemetryFromMeta, type AiTelemetryProps } from "./ai-identity.js";

// HTTP captures request-only metadata here so registry lifecycle events can
// include platform context without carrying raw params.
export interface InvocationMeta {
// include platform context (and the coarse AI client) without carrying raw params.
export interface InvocationMeta extends AiTelemetryProps {
platform?: "ios" | "android";
}

Expand Down Expand Up @@ -33,6 +34,7 @@ export function attachRegistryTelemetry(registry: Registry): AttachHandle {
tool: toolId,
tool_invocation_id: toolInvocationId,
...(meta.platform ? { platform: meta.platform } : {}),
...aiTelemetryFromMeta(meta),
});
};

Expand All @@ -43,6 +45,7 @@ export function attachRegistryTelemetry(registry: Registry): AttachHandle {
tool_invocation_id: toolInvocationId,
...(meta.platform ? { platform: meta.platform } : {}),
duration_ms: durationMs,
...aiTelemetryFromMeta(meta),
});
};

Expand All @@ -65,6 +68,7 @@ export function attachRegistryTelemetry(registry: Registry): AttachHandle {
...(meta.platform ? { platform: meta.platform } : {}),
duration_ms: durationMs,
...signal,
...aiTelemetryFromMeta(meta),
});
};

Expand Down
12 changes: 12 additions & 0 deletions packages/telemetry/src/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
NETWORK_FAILURES,
} from "@argent/registry";
import type { EventName } from "./events.js";
import { AI_CLIENTS, AI_CLIENT_NAME_PATTERN } from "./ai-identity.js";

// Per-event property allowlist and validators. Unknown keys and invalid values
// are dropped before anything reaches PostHog.
Expand Down Expand Up @@ -82,6 +83,14 @@ const FAILURE_SIGNAL_NAME = oneOf(FAILURE_SIGNAL_NAMES);
const FAILURE_SPAWN_CODE = oneOf(FAILURE_SPAWN_CODES);
const NETWORK_FAILURE = oneOf(NETWORK_FAILURES);

const AI_CLIENT = oneOf(AI_CLIENTS);
const AI_CLIENT_NAME = matches(AI_CLIENT_NAME_PATTERN, 80);

const AI_TELEMETRY = {
ai_client: AI_CLIENT,
ai_client_name: AI_CLIENT_NAME,
};

const FAILURE_SIGNAL = {
error_code: ERROR_CODE,
failure_stage: FAILURE_STAGE,
Expand Down Expand Up @@ -166,19 +175,22 @@ export const ALLOWED: Record<EventName, Record<string, Validator>> = {
tool: TOOL_NAME,
tool_invocation_id: UUID,
platform: PLATFORM,
...AI_TELEMETRY,
},
"tool:complete": {
tool: TOOL_NAME,
tool_invocation_id: UUID,
platform: PLATFORM,
duration_ms: DURATION_MS,
...AI_TELEMETRY,
},
"tool:fail": {
tool: TOOL_NAME,
tool_invocation_id: UUID,
platform: PLATFORM,
duration_ms: DURATION_MS,
...FAILURE_SIGNAL,
...AI_TELEMETRY,
},
"cli:run_fail": {
tool: TOOL_NAME,
Expand Down
64 changes: 64 additions & 0 deletions packages/telemetry/test/ai-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { AI_CLIENT_NAME_PATTERN, canonicalizeAiClient } from "../src/ai-identity.js";

describe("canonicalizeAiClient", () => {
describe("runtime MCP clientInfo.name (verified from each tool's source)", () => {
const RUNTIME_NAMES: Array<[string, string]> = [
["codex-mcp-client", "codex"],
["claude-code", "claude_code"],
["cursor-vscode", "cursor"],
["gemini-cli-mcp-client", "gemini"],
["gemini-cli-mcp-client-myserver", "gemini"], // older suffixed form
["Visual Studio Code", "vscode"],
["Visual Studio Code - Insiders", "vscode"],
["Code - OSS", "vscode"],
["windsurf", "windsurf"],
["Zed", "zed"],
["opencode", "opencode"],
["github-copilot-developer", "copilot"], // observed in real Copilot MCP logs
];

it.each(RUNTIME_NAMES)("maps %s → %s", (name, slug) => {
expect(canonicalizeAiClient(name)).toBe(slug);
});

it("does not mistake Codex's server-mode identity for the client", () => {
// We are always the server; we only ever receive `codex-mcp-client`. The
// server-mode string must not be matched by the client patterns.
expect(canonicalizeAiClient("codex-mcp-server")).toBeUndefined();
});
});

it("returns undefined for unrecognized clients (caller decides on `other`)", () => {
// `other` is a caller decision (raw clientInfo.name we couldn't map), not a
// value canonicalize should ever produce from an unknown string.
expect(canonicalizeAiClient("other")).toBeUndefined();
expect(canonicalizeAiClient("some-unknown-mcp-client")).toBeUndefined();
// Hermes sends a generic name and is no longer specially attributed.
expect(canonicalizeAiClient("mcp")).toBeUndefined();
});

it("returns undefined for empty / non-string input", () => {
expect(canonicalizeAiClient(undefined)).toBeUndefined();
expect(canonicalizeAiClient(null)).toBeUndefined();
expect(canonicalizeAiClient("")).toBeUndefined();
expect(canonicalizeAiClient(" ")).toBeUndefined();
});

it("tolerates surrounding whitespace", () => {
expect(canonicalizeAiClient(" codex-mcp-client ")).toBe("codex");
});
});

describe("AI_CLIENT_NAME_PATTERN", () => {
it("accepts real clientInfo.name values", () => {
for (const name of ["codex-mcp-client", "Visual Studio Code - Insiders", "Zed", "opencode"]) {
expect(AI_CLIENT_NAME_PATTERN.test(name)).toBe(true);
}
});

it("rejects path-like or oversized values", () => {
expect(AI_CLIENT_NAME_PATTERN.test("/Users/alice/secret-client")).toBe(false);
expect(AI_CLIENT_NAME_PATTERN.test("x".repeat(81))).toBe(false);
});
});
40 changes: 40 additions & 0 deletions packages/telemetry/test/registry-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,46 @@ describe("attachRegistryTelemetry", () => {
handle.detach();
});

it("threads the AI client through invoke / complete / fail events", () => {
const trackSpy = vi.spyOn(telemetry, "track");
const registry = new Registry();
const handle = attachRegistryTelemetry(registry);

handle.recordInvocation(INVOCATION_ID_1, { platform: "ios", ai_client: "codex" });
registry.events.emit("toolInvoked", "gesture-tap", INVOCATION_ID_1);
registry.events.emit("toolCompleted", "gesture-tap", INVOCATION_ID_1, 10);

handle.recordInvocation(INVOCATION_ID_2, {
ai_client: "other",
ai_client_name: "some-new-tool",
});
registry.events.emit("toolInvoked", "screenshot", INVOCATION_ID_2);
registry.events.emit("toolFailed", "screenshot", INVOCATION_ID_2, new Error("boom"), 5);

expect(trackSpy.mock.calls[0]![1]).toMatchObject({ ai_client: "codex" });
expect(trackSpy.mock.calls[1]![1]).toMatchObject({ ai_client: "codex" });
expect(trackSpy.mock.calls[3]![1]).toMatchObject({
ai_client: "other",
ai_client_name: "some-new-tool",
});

handle.detach();
});

it("omits AI keys entirely when no client metadata was recorded", () => {
const trackSpy = vi.spyOn(telemetry, "track");
const registry = new Registry();
const handle = attachRegistryTelemetry(registry);

handle.recordInvocation(INVOCATION_ID_1, { platform: "ios" });
registry.events.emit("toolInvoked", "gesture-tap", INVOCATION_ID_1);

expect(trackSpy.mock.calls[0]![1]).not.toHaveProperty("ai_client");
expect(trackSpy.mock.calls[0]![1]).not.toHaveProperty("ai_client_name");

handle.detach();
});

it("emits tool:fail with tool metadata and real duration", () => {
const trackSpy = vi.spyOn(telemetry, "track");
const registry = new Registry();
Expand Down
Loading