Skip to content

Commit a2d3157

Browse files
committed
fix(telemetry): defer native MCP usage recording until validation passes
1 parent 9d652bb commit a2d3157

4 files changed

Lines changed: 110 additions & 7 deletions

File tree

src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import type { Anthropic } from "@anthropic-ai/sdk"
44
import { describe, it, expect, beforeEach, vi } from "vitest"
55
import { presentAssistantMessage } from "../presentAssistantMessage"
66
import { validateToolUse } from "../../tools/validateToolUse"
7+
import { getModeBySlug } from "../../../shared/modes"
78
import type { Task } from "../../task/Task"
89

910
vi.mock("../../task/Task")
11+
vi.mock("../../../shared/modes", async (importOriginal) => {
12+
const actual = await importOriginal<typeof import("../../../shared/modes")>()
13+
return {
14+
...actual,
15+
getModeBySlug: vi.fn(actual.getModeBySlug),
16+
}
17+
})
1018
vi.mock("../../tools/validateToolUse", () => ({
1119
validateToolUse: vi.fn(),
1220
isValidToolName: vi.fn((toolName: string) =>
@@ -53,7 +61,12 @@ interface MockTask {
5361
recordToolUsage: ReturnType<typeof vi.fn>
5462
recordToolError: ReturnType<typeof vi.fn>
5563
toolRepetitionDetector: { check: ReturnType<typeof vi.fn> }
56-
providerRef: { deref: () => { getState: ReturnType<typeof vi.fn> } }
64+
providerRef: {
65+
deref: () => {
66+
getState: ReturnType<typeof vi.fn>
67+
getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined }
68+
}
69+
}
5770
say: ReturnType<typeof vi.fn>
5871
ask: ReturnType<typeof vi.fn>
5972
pushToolResultToUserContent: ReturnType<typeof vi.fn>
@@ -218,4 +231,80 @@ describe("presentAssistantMessage - tool usage attribution", () => {
218231
expect(mockTask.recordToolError).not.toHaveBeenCalledWith("totally_made_up_tool", expect.anything())
219232
expect(mockTask.recordToolUsage).not.toHaveBeenCalled()
220233
})
234+
235+
describe("native mcp_tool_use block", () => {
236+
it("records exactly one attempt once the MCP tool's own validation passes", async () => {
237+
mockTask.providerRef = {
238+
deref: () => ({
239+
getState: vi.fn().mockResolvedValue({
240+
mode: "code",
241+
customModes: [],
242+
}),
243+
getMcpHub: () => ({
244+
findServerNameBySanitizedName: () => "my_server",
245+
}),
246+
}),
247+
}
248+
249+
mockTask.assistantMessageContent = [
250+
{
251+
type: "mcp_tool_use",
252+
id: "call_native_mcp",
253+
name: "mcp_my_server_do_thing",
254+
serverName: "my_server",
255+
toolName: "do_thing",
256+
arguments: {},
257+
partial: false,
258+
},
259+
]
260+
261+
await presentAssistantMessage(mockTask as unknown as Task)
262+
263+
expect(mockTask.recordToolUsage).toHaveBeenCalledTimes(1)
264+
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool")
265+
expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledTimes(1)
266+
expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "use_mcp_tool")
267+
})
268+
269+
it("records no attempt when the MCP server is not on the mode's allow-list", async () => {
270+
vi.mocked(getModeBySlug).mockReturnValueOnce({
271+
slug: "code",
272+
name: "Code",
273+
roleDefinition: "",
274+
groups: [],
275+
allowedMcpServers: ["some-other-server"],
276+
})
277+
278+
mockTask.providerRef = {
279+
deref: () => ({
280+
getState: vi.fn().mockResolvedValue({
281+
mode: "code",
282+
customModes: [],
283+
}),
284+
getMcpHub: () => ({
285+
findServerNameBySanitizedName: () => "my_server",
286+
}),
287+
}),
288+
}
289+
290+
mockTask.assistantMessageContent = [
291+
{
292+
type: "mcp_tool_use",
293+
id: "call_native_mcp_disallowed",
294+
name: "mcp_my_server_do_thing",
295+
serverName: "my_server",
296+
toolName: "do_thing",
297+
arguments: {},
298+
partial: false,
299+
},
300+
]
301+
302+
await presentAssistantMessage(mockTask as unknown as Task)
303+
304+
// The server is disallowed, so the call never reaches onValidated:
305+
// no success attempt is recorded for a call that was never permitted to execute.
306+
expect(mockTask.recordToolUsage).not.toHaveBeenCalled()
307+
expect(TelemetryService.instance.captureToolUsage).not.toHaveBeenCalled()
308+
})
309+
})
221310
})

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,6 @@ export async function presentAssistantMessage(cline: Task) {
258258
pushToolResult(formatResponse.toolError(errorString))
259259
}
260260

261-
if (!mcpBlock.partial) {
262-
cline.recordToolUsage("use_mcp_tool") // Record as use_mcp_tool for analytics
263-
TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool")
264-
}
265-
266261
// Resolve sanitized server name back to original server name
267262
// The serverName from parsing is sanitized (e.g., "my_server" from "my server")
268263
// We need the original name to find the actual MCP connection
@@ -298,6 +293,12 @@ export async function presentAssistantMessage(cline: Task) {
298293
askApproval,
299294
handleError,
300295
pushToolResult,
296+
onValidated: mcpBlock.partial
297+
? undefined
298+
: () => {
299+
cline.recordToolUsage("use_mcp_tool")
300+
TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool")
301+
},
301302
})
302303
break
303304
}

src/core/tools/BaseTool.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ export interface ToolCallbacks {
1111
handleError: HandleError
1212
pushToolResult: PushToolResult
1313
toolCallId?: string
14+
/**
15+
* Optional hook invoked once a tool's own internal validation (params,
16+
* target existence, permissions) has passed, before side-effecting
17+
* execution begins. Used by callers that must defer telemetry attribution
18+
* until validation this deep can't be done from the outside (e.g. native
19+
* MCP tool calls, whose server/tool/allow-list checks live inside
20+
* UseMcpToolTool rather than the shared validateToolUse path).
21+
*/
22+
onValidated?: () => void
1423
}
1524

1625
/**

src/core/tools/UseMcpToolTool.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
2828
readonly name = "use_mcp_tool" as const
2929

3030
async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
31-
const { askApproval, handleError, pushToolResult } = callbacks
31+
const { askApproval, handleError, pushToolResult, onValidated } = callbacks
3232

3333
try {
3434
// Validate parameters
@@ -66,6 +66,10 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> {
6666
// Reset mistake count on successful validation
6767
task.consecutiveMistakeCount = 0
6868

69+
// All internal validation (params, tool existence, server allow-list) has
70+
// passed. Only now is it safe to attribute this as an attempted tool use.
71+
onValidated?.()
72+
6973
// Get user approval
7074
const completeMessage = JSON.stringify({
7175
type: "use_mcp_tool",

0 commit comments

Comments
 (0)