diff --git a/README.md b/README.md index e5cbe35..be1f095 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,8 @@ python trace.py The core SDKs and most integrations capture inputs and outputs by default. To disable input and output capture, set `captureInput: false` and `captureOutput: false` in TypeScript `init()`. For Python, use `capture_input=False` and `capture_output=False`. -Some integrations work differently: MCP captures no tool arguments or results unless you pass -`capturePayloads: true`, the AI SDK integration follows AI SDK's `recordInputs` and `recordOutputs`, +Some integrations work differently: MCP also drops tool arguments and results when you pass +`capturePayloads: false`, the AI SDK integration follows AI SDK's `recordInputs` and `recordOutputs`, Cursor reads `captureInput` and `captureOutput` from its own config file, and TanStack AI has no capture setting of its own and records chat messages, tool payloads, and error messages. Disabling output capture omits message content; response IDs and models, finish reasons, usage, diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 6d2d7ec..d566490 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -64,7 +64,7 @@ Request spans also carry `mcp.client.name` and `mcp.client.version`, from the `c ## Payload capture -Tool arguments and successful tool results are not captured by default. Enable them with `instrumentMcpTransport(transport, { capturePayloads: true })`. Core `@telemetry-dev/sdk` capture and masking settings still apply. +`tools/call` spans record the tool arguments and the tool result, including a result with `isError: true`. Core `@telemetry-dev/sdk` settings still apply: `captureInput: false` drops the arguments, `captureOutput: false` drops the result, and `mask` runs on both. To leave tool payloads out while the core SDK captures other content, pass `instrumentMcpTransport(transport, { capturePayloads: false })`. Error codes and the JSON-RPC error message are recorded either way; `captureOutput: false` and `mask` do not apply to them. Trace context propagates by default. OpenTelemetry baggage remains local unless you enable it with `instrumentMcpTransport(transport, { propagateBaggage: true })`. diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index aefeaec..483ecbd 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -18,6 +18,11 @@ interface McpTransport { } export interface InstrumentMcpTransportOptions { + /** + * Record tool call arguments and results, failed results included, on `tools/call` spans. + * Defaults to true; the core SDK's captureInput, captureOutput, and mask still apply. Set false + * to keep tool payloads out even when the core SDK captures content. + */ capturePayloads?: boolean; propagateBaggage?: boolean; } @@ -354,7 +359,8 @@ function finishResponse( setFailure(pending, "tool_error"); } - if (pending.capturePayloads && pending.method === "tools/call" && result?.isError !== true) { + // A failed result's content says what went wrong, so it is kept like a successful one. + if (pending.capturePayloads && pending.method === "tools/call") { pending.handle.update({ output: message.message.result }); } } @@ -424,7 +430,7 @@ export function instrumentMcpTransport( const incoming = new Map(); const sendingResponses = new Set(); const dispatching = new WeakSet(); - const capturePayloads = options.capturePayloads === true; + const capturePayloads = options.capturePayloads !== false; const propagateBaggage = options.propagateBaggage === true; let onmessage = target.onmessage; diff --git a/packages/mcp/tests/mcp.test.ts b/packages/mcp/tests/mcp.test.ts index 69d8d43..7e74910 100644 --- a/packages/mcp/tests/mcp.test.ts +++ b/packages/mcp/tests/mcp.test.ts @@ -35,10 +35,13 @@ interface JsonRecord { [key: string]: JsonValue | undefined; } -function setup(): InMemorySpanExporter { +function setup( + capture: { captureInput?: boolean; captureOutput?: boolean } = {}, +): InMemorySpanExporter { const spans = new InMemorySpanExporter(); init( { + ...capture, apiKey: "td_live_test", serviceName: "mcp-tests", environment: "test", @@ -69,11 +72,11 @@ afterEach(async () => { await shutdown(); }); -test("instruments both v2 transport directions with propagation and opt-in payloads", async () => { +test("instruments both v2 transport directions with propagation and payloads", async () => { const spans = setup(); const [clientRaw, serverRaw] = InMemoryTransport.createLinkedPair(); - const client = instrumentMcpTransport(clientRaw, { capturePayloads: true }); - const server = instrumentMcpTransport(serverRaw, { capturePayloads: true }); + const client = instrumentMcpTransport(clientRaw); + const server = instrumentMcpTransport(serverRaw); expect(instrumentMcpTransport(client)).toBe(clientRaw); client.onmessage = () => undefined; @@ -294,6 +297,57 @@ test.each([ expect(serverSpan.status.code).toBe(serverFault ? SpanStatusCode.ERROR : SpanStatusCode.UNSET); }); +test.each([ + { name: "capturePayloads false", capture: {}, payloads: false, args: false, result: false }, + { + name: "core captureInput false", + capture: { captureInput: false }, + payloads: true, + args: false, + result: true, + }, + { + name: "core captureOutput false", + capture: { captureOutput: false }, + payloads: true, + args: true, + result: false, + }, +])("$name keeps tool payloads out", async ({ capture, payloads, args, result }) => { + const spans = setup(capture); + + const [client, server] = InMemoryTransport.createLinkedPair().map((transport) => + instrumentMcpTransport(transport, payloads ? {} : { capturePayloads: false }), + ); + + client.onmessage = () => undefined; + server.onmessage = () => { + void server.send({ + jsonrpc: "2.0", + id: 5, + result: { content: [{ type: "text", text: "ok" }] }, + }); + }; + + await client.start(); + await server.start(); + await client.send({ + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { name: "weather", arguments: { city: "Paris" } }, + }); + + for (const span of await exported(spans, 2)) { + expect(span.attributes["gen_ai.tool.call.arguments"]).toBe( + args ? '{"city":"Paris"}' : undefined, + ); + expect(span.attributes["gen_ai.tool.call.result"]).toBe( + result ? '{"content":[{"type":"text","text":"ok"}]}' : undefined, + ); + } +}); + test("marks CallToolResult errors and closes pending requests", async () => { const spans = setup(); @@ -317,6 +371,10 @@ test("marks CallToolResult errors and closes pending requests", async () => { const toolSpans = await exported(spans, 2); expect(toolSpans.every((span) => span.attributes["error.type"] === "tool_error")).toBe(true); expect(toolSpans.every((span) => span.status.code === SpanStatusCode.ERROR)).toBe(true); + expect(toolSpans.map((span) => span.attributes["gen_ai.tool.call.result"])).toEqual([ + '{"content":[{"type":"text","text":"failed"}],"isError":true}', + '{"content":[{"type":"text","text":"failed"}],"isError":true}', + ]); await shutdown(); const closeSpans = setup();