Skip to content

Commit a1e2f80

Browse files
Merge remote-tracking branch 'origin/main' into fix/issue-24098
2 parents f6e79b5 + fef851b commit a1e2f80

2 files changed

Lines changed: 67 additions & 5 deletions

File tree

‎apps/server/src/mcp/McpHttpServer.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -816,9 +816,10 @@ const handoffTargetIds = (
816816
};
817817

818818
/**
819-
* Runs a tool registration against a server that records one anonymous
820-
* `mcp.tool.invoked` event per call: the tool, the calling agent's provider,
821-
* the outcome with its failure code, and the duration. A handoff tool also
819+
* Runs a tool registration against a server that records an anonymous
820+
* `mcp.tool.invoked` event for every handoff call and every failed call of
821+
* other tools: the tool, the calling agent's provider, the outcome with its
822+
* failure code, and the duration. A handoff tool also
822823
* reports the caller's model, origin, and delegation depth, the settings the
823824
* agent chose, and the provider, model, and modes of each thread that received
824825
* the work. Ids, prompts, and free text are never recorded.
@@ -882,14 +883,18 @@ const withToolAnalytics =
882883
result: McpSchema.CallToolResult | undefined,
883884
durationMs: number,
884885
) {
886+
const handoff = HANDOFF_TOOLS.has(tool);
887+
const outcome = toolOutcome(result);
888+
// Agents call status, read, and preview tools in tight loops, so an
889+
// event per successful call floods analytics. Only failures count there.
890+
if (!handoff && outcome.outcome === "ok") return;
885891
// Client credentials (no calling thread) report an unknown caller.
886892
const invocation = Option.getOrUndefined(
887893
yield* Effect.serviceOption(McpInvocationContext.McpInvocationContext),
888894
)?.thread;
889895
const providers = Option.isSome(registry) ? yield* registry.value.getProviders : [];
890896
// Only handoff tools pay for thread lookups; the rest report the
891897
// caller's provider from the credential.
892-
const handoff = HANDOFF_TOOLS.has(tool);
893898
const caller =
894899
handoff && invocation !== undefined
895900
? ((yield* shellOf(invocation.threadId)) ?? undefined)
@@ -917,7 +922,7 @@ const withToolAnalytics =
917922
),
918923
}),
919924
targets: targets.filter((thread) => thread !== null),
920-
outcome: toolOutcome(result),
925+
outcome,
921926
...(handoff
922927
? { settings: handoffSettings(tool, args, result?.structuredContent) }
923928
: {}),

‎apps/server/src/mcp/toolkits/core.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type OrchestrationV2ThreadShell,
1919
} from "@t3tools/contracts";
2020
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
21+
import * as Deferred from "effect/Deferred";
2122
import * as Effect from "effect/Effect";
2223
import * as TestClock from "effect/testing/TestClock";
2324
import * as Layer from "effect/Layer";
@@ -48,6 +49,7 @@ import * as ServerSettings from "../../serverSettings.ts";
4849
import * as McpHttpServer from "../McpHttpServer.ts";
4950
import * as McpInvocationContext from "../McpInvocationContext.ts";
5051
import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
52+
import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
5153
import { dispatchFailure } from "../threadAccess.ts";
5254
import * as EnvironmentHandlers from "./environment/handlers.ts";
5355
import * as AttachmentHandlers from "./attachment/handlers.ts";
@@ -287,6 +289,61 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both
287289
),
288290
);
289291

292+
it.effect("records only failed calls of tools that hand off no work", () => {
293+
// Recording runs detached from the tool response, so the first event is awaited.
294+
const recorded = Deferred.makeUnsafe<{ event: string; properties: object }>();
295+
return Effect.gen(function* () {
296+
const server = yield* McpServer.McpServer;
297+
const organize = (capabilities: McpInvocationContext.McpInvocationScope["capabilities"]) =>
298+
server.callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }).pipe(
299+
Effect.provideService(McpInvocationContext.McpInvocationContext, {
300+
...scope,
301+
capabilities,
302+
}),
303+
Effect.provideService(McpSchema.McpServerClient, client),
304+
);
305+
expect((yield* organize(scope.capabilities)).isError).not.toBe(true);
306+
expect(declaredFailure(yield* organize(new Set<never>()))).toMatchObject({
307+
code: "capability_denied",
308+
});
309+
// The successful call ran first, so it would be the first event if it were recorded.
310+
expect(yield* Deferred.await(recorded)).toMatchObject({
311+
event: "mcp.tool.invoked",
312+
properties: { tool: "t3_thread_organize", outcome: "error" },
313+
});
314+
}).pipe(
315+
Effect.provide(
316+
layerThreadToolkit.pipe(
317+
Layer.provideMerge(McpServer.McpServer.layer),
318+
Layer.provide(NodeCrypto.layer),
319+
Layer.provide(
320+
Layer.mock(ThreadManagement.ThreadManagementService)({
321+
getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)),
322+
getProjectThreadRecords: ({ threadId: id }) =>
323+
Effect.succeed(
324+
McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)),
325+
),
326+
dispatch: () => Effect.succeed({ sequence: 1 } as never),
327+
}),
328+
),
329+
Layer.provide(
330+
Layer.succeed(
331+
AnalyticsService.AnalyticsService,
332+
AnalyticsService.AnalyticsService.of({
333+
record: (event, properties) =>
334+
Deferred.succeed(recorded, { event, properties: properties ?? {} }).pipe(
335+
Effect.asVoid,
336+
),
337+
flush: Effect.void,
338+
recordOwners: () => Effect.void,
339+
}),
340+
),
341+
),
342+
),
343+
),
344+
);
345+
});
346+
290347
it.effect("keeps unexpected handler defects private through the production registration", () =>
291348
Effect.gen(function* () {
292349
const server = yield* McpServer.McpServer;

0 commit comments

Comments
 (0)