Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 })`.

Expand Down
10 changes: 8 additions & 2 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -424,7 +430,7 @@ export function instrumentMcpTransport<T extends McpTransport>(
const incoming = new Map<JsonRpcId, PendingRequest>();
const sendingResponses = new Set<PendingRequest>();
const dispatching = new WeakSet<object>();
const capturePayloads = options.capturePayloads === true;
const capturePayloads = options.capturePayloads !== false;
const propagateBaggage = options.propagateBaggage === true;
let onmessage = target.onmessage;

Expand Down
66 changes: 62 additions & 4 deletions packages/mcp/tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand All @@ -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();
Expand Down
Loading