feat: report MCP client telemetry (#242)

This commit is contained in:
Andrey Avtomonov 2026-05-30 18:00:25 +02:00 committed by GitHub
parent 25f639fba2
commit 2e5f7f25aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 216 additions and 29 deletions

View file

@ -6,6 +6,7 @@ import type { MemoryAgentInput } from '../../context/memory/types.js';
import { emitTelemetryEvent, mcpTelemetrySampleRate, shouldEmitMcpTelemetry } from '../../telemetry/index.js';
import { scrubErrorClass } from '../../telemetry/scrubber.js';
import type {
KtxMcpClientInfo,
KtxMcpContextPorts,
KtxMcpProgressCallback,
KtxMcpServerLike,
@ -22,6 +23,7 @@ export interface RegisterKtxContextToolsDeps {
userContext: KtxMcpUserContext;
projectDir?: string;
io?: KtxCliIo;
getClientInfo?: () => KtxMcpClientInfo | undefined;
}
const connectionIdSchema = z.string().min(1);
@ -526,9 +528,24 @@ function registerParsedTool<TSchema extends z.ZodType>(
});
}
/**
* Resolves the connected client's identity into the raw telemetry fields. The
* strings are client-controlled and untrusted, so they only ever land in the
* telemetry property bag never in paths, logs, or error messages.
*/
function clientTelemetryFields(
getClientInfo: (() => KtxMcpClientInfo | undefined) | undefined,
): { mcpClientName?: string; mcpClientVersion?: string } {
const client = getClientInfo?.();
return {
...(client?.name ? { mcpClientName: client.name } : {}),
...(client?.version ? { mcpClientVersion: client.version } : {}),
};
}
function instrumentMcpServer(
server: KtxMcpServerLike,
telemetry: { projectDir?: string; io?: KtxCliIo },
telemetry: { projectDir?: string; io?: KtxCliIo; getClientInfo?: () => KtxMcpClientInfo | undefined },
): KtxMcpServerLike {
return {
registerTool(name, config, handler) {
@ -548,6 +565,7 @@ function instrumentMcpServer(
outcome: isError ? 'error' : 'ok',
durationMs: Math.max(0, performance.now() - startedAt),
sampleRate: mcpTelemetrySampleRate(),
...clientTelemetryFields(telemetry.getClientInfo),
},
});
}
@ -565,6 +583,7 @@ function instrumentMcpServer(
...(errorClass ? { errorClass } : {}),
durationMs: Math.max(0, performance.now() - startedAt),
sampleRate: mcpTelemetrySampleRate(),
...clientTelemetryFields(telemetry.getClientInfo),
},
});
}
@ -577,7 +596,11 @@ function instrumentMcpServer(
export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void {
const { ports, userContext } = deps;
const server = instrumentMcpServer(deps.server, { projectDir: deps.projectDir, io: deps.io });
const server = instrumentMcpServer(deps.server, {
projectDir: deps.projectDir,
io: deps.io,
getClientInfo: deps.getClientInfo,
});
if (ports.connections) {
const connections = ports.connections;

View file

@ -11,6 +11,7 @@ export function createKtxMcpServer(deps: KtxMcpServerDeps): KtxMcpServerDeps['se
userContext: deps.userContext,
projectDir: deps.projectDir,
io: deps.io,
getClientInfo: deps.getClientInfo,
});
}
@ -30,6 +31,9 @@ export function createDefaultKtxMcpServer(
contextTools: deps.contextTools,
projectDir: deps.projectDir,
io: deps.io,
// The SDK populates the client identity after the initialize handshake, so
// read it lazily at emit time rather than at registration (undefined here).
getClientInfo: () => server.server.getClientVersion(),
});
return server;
}

View file

@ -50,6 +50,16 @@ export interface KtxMcpUserContext {
userId: string;
}
/**
* Identity of the connected MCP client tool (e.g. Claude Desktop, Cursor),
* read from the initialize handshake. Untrusted, client-controlled strings
* use only as telemetry properties, never to build paths or log lines.
*/
export interface KtxMcpClientInfo {
name: string;
version: string;
}
export interface KtxMcpServerLike {
registerTool(
name: string,
@ -177,4 +187,6 @@ export interface KtxMcpServerDeps {
contextTools?: KtxMcpContextPorts;
projectDir?: string;
io?: KtxCliIo;
/** Reads the connected client's identity once the initialize handshake completes. */
getClientInfo?: () => KtxMcpClientInfo | undefined;
}