Skip to content

Commit e3d414c

Browse files
committed
feat(telemetry): wire prod MCP callers and instrument the auth cap denial
Prod MCP connects never received a telemetry handle, so mcp_oauth stayed silent outside tests; the browser-auth attempt cap also threw before a flow existed, skipping the instrumented recovery path. The toolset now forwards its telemetry to each connect and web-search uses the ambient singleton, and the cap throw emits a terminal timeout like every other non-abort browser-wait end.
1 parent 67d53b5 commit e3d414c

5 files changed

Lines changed: 151 additions & 2 deletions

File tree

‎src/agent/tools.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,6 +1264,9 @@ export async function createAgentToolset(
12641264
try {
12651265
result = await connectMCPClient(config, {
12661266
stderr: "ignore",
1267+
...(args.telemetry !== undefined
1268+
? { telemetry: args.telemetry }
1269+
: {}),
12671270
...(callbacks.interactiveAuth
12681271
? {
12691272
onAuthURL: (name: string, url: string) => {

‎src/mcp/client-auth-reauth-cap.test.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
test,
88
} from "bun:test";
99
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
10+
import type { Telemetry } from "../telemetry/index.js";
1011
import { withMockedModule } from "../../tests/helpers/mock-module.js";
1112

1213
let finishAuthCalls = 0;
@@ -263,7 +264,7 @@ const config = {
263264
url: "https://mcp.linear.app/mcp",
264265
};
265266

266-
async function connectWithAuthPrompt(): Promise<{
267+
async function connectWithAuthPrompt(telemetry?: Telemetry): Promise<{
267268
ok: boolean;
268269
error?: string;
269270
authPending?: boolean;
@@ -273,6 +274,7 @@ async function connectWithAuthPrompt(): Promise<{
273274
authURLCount += 1;
274275
authEvents.push("authURL");
275276
},
277+
...(telemetry !== undefined ? { telemetry } : {}),
276278
});
277279
return result.ok
278280
? { ok: true }
@@ -864,6 +866,46 @@ describe("HTTP MCP re-auth loop prevention", () => {
864866
expect(providerCreates).toBeGreaterThan(MAX_BROWSER_AUTH_ATTEMPTS);
865867
});
866868

869+
test("a capped browser-auth denial emits one mcp_oauth timeout", async () => {
870+
const oauthResults: unknown[] = [];
871+
const telemetry: Telemetry = {
872+
enabled: true,
873+
installationId: "test",
874+
capture: (event, properties) => {
875+
if (event === "mcp_oauth")
876+
oauthResults.push(
877+
(properties as Record<string, unknown> | undefined)?.result,
878+
);
879+
},
880+
captureIntentional: () => false,
881+
flush: async () => undefined,
882+
discard: () => undefined,
883+
};
884+
const connectWithTelemetry = () => connectWithAuthPrompt(telemetry);
885+
connectFailuresLeft = Number.POSITIVE_INFINITY;
886+
887+
for (let episode = 0; episode < MAX_BROWSER_AUTH_ATTEMPTS; episode += 1) {
888+
const result = await connectWithTelemetry();
889+
expect(result.ok).toBe(false);
890+
expect(result.error).toContain("finishAuth exploded");
891+
}
892+
expect(authURLCount).toBe(MAX_BROWSER_AUTH_ATTEMPTS);
893+
// Each failed episode emits exactly one timeout through recovery.
894+
expect(oauthResults).toEqual(
895+
Array.from({ length: MAX_BROWSER_AUTH_ATTEMPTS }, () => "timeout"),
896+
);
897+
898+
const capped = await connectWithTelemetry();
899+
expect(capped.ok).toBe(false);
900+
expect(capped.error).toContain("retrying paused");
901+
expect(authURLCount).toBe(MAX_BROWSER_AUTH_ATTEMPTS);
902+
// The cap denial exits before a browser flow exists, but it still maps
903+
// to exactly one terminal timeout rather than staying silent.
904+
expect(oauthResults).toEqual(
905+
Array.from({ length: MAX_BROWSER_AUTH_ATTEMPTS + 1 }, () => "timeout"),
906+
);
907+
});
908+
867909
test("successful interactive auth clears the cap so a later failure can prompt", async () => {
868910
finishAuthError = undefined;
869911
connectFailuresLeft = 1;

‎src/mcp/client-telemetry.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { beforeEach, describe, expect, test } from "bun:test";
2+
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
3+
import { withMockedModule } from "../../tests/helpers/mock-module.js";
4+
5+
let connectCalls = 0;
6+
let failOnCall = -1;
7+
const connectError = new Error("spawn ENOENT");
8+
9+
await withMockedModule(
10+
import.meta.resolve("@modelcontextprotocol/sdk/client/index.js"),
11+
(real: typeof import("@modelcontextprotocol/sdk/client/index.js")) => ({
12+
...real,
13+
Client: class {
14+
async connect(): Promise<void> {
15+
connectCalls += 1;
16+
if (connectCalls === failOnCall) throw connectError;
17+
}
18+
async listTools(): Promise<{ tools: [] }> {
19+
return { tools: [] };
20+
}
21+
async close(): Promise<void> {
22+
return undefined;
23+
}
24+
},
25+
}),
26+
);
27+
28+
const { connectMCPServers } = await import("./client.js");
29+
30+
function recordingTelemetry(events: Record<string, unknown>[]): Telemetry {
31+
return {
32+
enabled: true,
33+
installationId: "test",
34+
capture: (event, properties) => {
35+
if (event === "mcp_connect") events.push({ ...(properties ?? {}) });
36+
},
37+
captureIntentional: () => false,
38+
flush: async () => undefined,
39+
discard: () => undefined,
40+
};
41+
}
42+
43+
describe("connectMCPServers telemetry", () => {
44+
beforeEach(() => {
45+
connectCalls = 0;
46+
failOnCall = -1;
47+
});
48+
49+
test("a mixed multi-config run emits exactly one mcp_connect per config", async () => {
50+
failOnCall = 2;
51+
const events: Record<string, unknown>[] = [];
52+
const warnings: string[] = [];
53+
const clients = await connectMCPServers(
54+
[
55+
{ name: "a", type: "stdio", command: "test-server-a" },
56+
{ name: "b", type: "stdio", command: "test-server-b" },
57+
],
58+
(message) => warnings.push(message),
59+
{ telemetry: recordingTelemetry(events) },
60+
);
61+
try {
62+
expect(clients).toHaveLength(1);
63+
expect(warnings).toHaveLength(1);
64+
expect(events).toHaveLength(2);
65+
expect(events.map((event) => event.transport)).toEqual([
66+
"stdio",
67+
"stdio",
68+
]);
69+
expect(events.map((event) => event.result)).toEqual(["ok", "fail"]);
70+
} finally {
71+
for (const client of clients) await client.close();
72+
}
73+
});
74+
75+
test("omitted telemetry stays silent on the dropping default", async () => {
76+
// The omitted path routes capture calls into NOOP_TELEMETRY, whose
77+
// capture drops everything — the gate lives in the Telemetry handle,
78+
// not at the call site. This keeper pins that contract: the default
79+
// handle is disabled, and connecting without telemetry resolves
80+
// normally with nothing to flush or send.
81+
expect(NOOP_TELEMETRY.enabled).toBe(false);
82+
const warnings: string[] = [];
83+
const clients = await connectMCPServers(
84+
[{ name: "a", type: "stdio", command: "test-server-a" }],
85+
(message) => warnings.push(message),
86+
);
87+
try {
88+
expect(clients).toHaveLength(1);
89+
expect(warnings).toHaveLength(0);
90+
} finally {
91+
for (const client of clients) await client.close();
92+
}
93+
});
94+
});

‎src/mcp/client.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,13 @@ function gateRedirectToAuthorization(context: HTTPAuthContext): void {
365365
return promptEmitted;
366366
} catch (err) {
367367
unfreezePkce(coordinator);
368+
// The cap throws before a browser flow exists, so this denial never
369+
// reaches the instrumented recovery path below — emit here so the
370+
// terminal denial maps to timeout like every other non-abort end.
371+
captureMcpOauth(
372+
context.telemetry,
373+
classifyMcpOauthOutcome(err, coordinator.lifecycle.signal.aborted),
374+
);
368375
throw err;
369376
}
370377
};

‎src/tools/web-search.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ToolDefinition } from "@intx/types/runtime";
55

66
import { connectMCPServer, type MCPClient } from "../mcp/client.js";
77
import type { MCPServerConfig } from "../config/settings.js";
8+
import { getTelemetry } from "../telemetry/singleton.js";
89
import { EXA_MCP_URL } from "../mcp/exa.js";
910

1011
export { EXA_MCP_URL } from "../mcp/exa.js";
@@ -93,7 +94,9 @@ async function getClient(provider: WebSearchProviderId): Promise<MCPClient> {
9394
type: "http",
9495
url: endpointFor(provider),
9596
};
96-
const connecting = connectMCPServer(config).then((result) => {
97+
const connecting = connectMCPServer(config, {
98+
telemetry: getTelemetry(),
99+
}).then((result) => {
97100
if (!result.ok) {
98101
clientCache.delete(provider);
99102
throw new Error(result.error);

0 commit comments

Comments
 (0)