diff --git a/.changeset/add-mcp-server-tracing.md b/.changeset/add-mcp-server-tracing.md new file mode 100644 index 000000000..567117d2c --- /dev/null +++ b/.changeset/add-mcp-server-tracing.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat(mcp): Add MCP server tracing diff --git a/e2e/config/pr-comment-scenarios.json b/e2e/config/pr-comment-scenarios.json index 5ffd89823..75bd5ecbb 100644 --- a/e2e/config/pr-comment-scenarios.json +++ b/e2e/config/pr-comment-scenarios.json @@ -592,6 +592,29 @@ } ] }, + { + "scenarioDirName": "mcp-instrumentation", + "label": "MCP Instrumentation", + "metadataScenario": "mcp-instrumentation", + "variants": [ + { + "variantKey": "mcp-sdk-v1", + "label": "sdk v1 pinned" + }, + { + "variantKey": "mcp-sdk-v1-latest", + "label": "sdk v1 latest" + }, + { + "variantKey": "mcp-server-v2", + "label": "server v2 pinned" + }, + { + "variantKey": "mcp-server-v2-latest", + "label": "server v2 latest" + } + ] + }, { "scenarioDirName": "typesafe-instrumentation", "label": "TypeSafe Instrumentation", diff --git a/e2e/helpers/normalize.test.ts b/e2e/helpers/normalize.test.ts index b5a43532a..fe645aa72 100644 --- a/e2e/helpers/normalize.test.ts +++ b/e2e/helpers/normalize.test.ts @@ -2,6 +2,33 @@ import { describe, expect, test } from "vitest"; import { normalizeForSnapshot } from "./normalize"; describe("normalizeForSnapshot", () => { + test.each([0, 0.1, 1.2])( + "normalizes tool wall time of %s seconds", + (seconds) => { + expect( + normalizeForSnapshot({ + output: { + structuredContent: { + exit_code: 0, + output: "pi_tool_ok", + truncated: false, + wall_time_seconds: seconds, + }, + }, + }), + ).toEqual({ + output: { + structuredContent: { + exit_code: 0, + output: "pi_tool_ok", + truncated: false, + wall_time_seconds: 0, + }, + }, + }); + }, + ); + test("normalizes a foreign repository path ending at a workspace directory", () => { expect( normalizeForSnapshot( diff --git a/e2e/helpers/normalize.ts b/e2e/helpers/normalize.ts index c0c79b904..ec994ded5 100644 --- a/e2e/helpers/normalize.ts +++ b/e2e/helpers/normalize.ts @@ -75,6 +75,7 @@ const ZERO_NUMBER_KEYS = new Set([ "duration", "github_copilot.context_window.current", "time_to_first_token", + "wall_time_seconds", ]); const XACT_VERSION_KEYS = new Set([ "currentVersion", diff --git a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.json b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.json new file mode 100644 index 000000000..dd9e0ab2f --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.json @@ -0,0 +1,461 @@ +{ + "span_tree": [ + { + "name": "mcp-instrumentation-root", + "type": "task", + "children": [ + { + "name": "mcp-http-session-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Vienna" + }, + "output": { + "content": [ + { + "text": "Sunny in Vienna", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Paris" + }, + "output": { + "content": [ + { + "text": "Sunny in Paris", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "resources/read", + "type": "function", + "children": [], + "input": { + "uri": "notes://forecast" + }, + "output": { + "contents": [ + { + "text": "Bring an umbrella.", + "uri": "notes://forecast" + } + ] + }, + "metadata": { + "mcp.method": "resources/read", + "mcp.request_id": 3, + "mcp.resource_uri": "notes://forecast", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "prompts/get", + "type": "function", + "children": [], + "input": { + "city": "Vienna" + }, + "output": { + "messages": [ + { + "content": { + "text": "What is the forecast for Vienna?", + "type": "text" + }, + "role": "user" + } + ] + }, + "metadata": { + "mcp.method": "prompts/get", + "mcp.prompt_name": "forecast", + "mcp.request_id": 4, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + } + ], + "metadata": { + "operation": "http-session", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + }, + { + "name": "mcp-in-memory-session-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Tokyo" + }, + "output": { + "content": [ + { + "text": "Sunny in Tokyo", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.client.name": "trace-client", + "mcp.client.version": "1.0.0", + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "render_map", + "type": "tool", + "children": [], + "output": { + "content": [ + { + "data": "", + "mimeType": "image/png", + "type": "image" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_alerts", + "type": "tool", + "children": [], + "output": { + "content": [ + { + "text": "Alert service unavailable", + "type": "text" + } + ], + "isError": true + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 3, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "Alert service unavailable" + } + ], + "metadata": { + "operation": "in-memory-session", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + }, + { + "name": "mcp-lifecycle-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "ping", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "ping", + "mcp.request_id": 100, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "tools/list", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "tools/list", + "mcp.request_id": 101, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "acme/missing", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "acme/missing", + "mcp.request_id": 102, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "MCP error -32601: Method not found" + }, + { + "name": "get_weather", + "type": "tool", + "children": [], + "input": { + "city": 42 + }, + "output": { + "content": [ + { + "text": "MCP error -32602: Input validation error: Invalid arguments for tool get_weather: Invalid input: expected string, received number at city", + "type": "text" + } + ], + "isError": true + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 103, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "MCP error -32602: Input validation error: Invalid arguments for tool get_weather: Invalid input: expected string, received number at city" + }, + { + "name": "wait_for_cancel", + "type": "tool", + "children": [], + "metadata": { + "mcp.cancelled": true, + "mcp.method": "tools/call", + "mcp.request_id": 104, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "acme/cancel", + "type": "function", + "children": [], + "metadata": { + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + } + ], + "metadata": { + "operation": "lifecycle", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "metadata": { + "scenario": "mcp-instrumentation", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ] +} diff --git a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.txt b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.txt new file mode 100644 index 000000000..a9b051ee3 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.txt @@ -0,0 +1,365 @@ +span_tree: +└── mcp-instrumentation-root [task] + metadata: { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + metrics: { + "end": 0, + "start": 0 + } + context: { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + ├── mcp-http-session-operation + │ metadata: { + │ "operation": "http-session", + │ "testRunId": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ context: { + │ "caller_filename": "", + │ "caller_functionname": "", + │ "caller_lineno": 0 + │ } + │ ├── initialize [function] + │ │ metadata: { + │ │ "mcp.method": "initialize", + │ │ "mcp.request_id": 0, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Vienna" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Vienna", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Paris" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Paris", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── resources/read [function] + │ │ input: { + │ │ "uri": "notes://forecast" + │ │ } + │ │ output: { + │ │ "contents": [ + │ │ { + │ │ "text": "Bring an umbrella.", + │ │ "uri": "notes://forecast" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "resources/read", + │ │ "mcp.request_id": 3, + │ │ "mcp.resource_uri": "notes://forecast", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ └── prompts/get [function] + │ input: { + │ "city": "Vienna" + │ } + │ output: { + │ "messages": [ + │ { + │ "content": { + │ "text": "What is the forecast for Vienna?", + │ "type": "text" + │ }, + │ "role": "user" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "prompts/get", + │ "mcp.prompt_name": "forecast", + │ "mcp.request_id": 4, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── mcp-in-memory-session-operation + │ metadata: { + │ "operation": "in-memory-session", + │ "testRunId": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ context: { + │ "caller_filename": "", + │ "caller_functionname": "", + │ "caller_lineno": 0 + │ } + │ ├── initialize [function] + │ │ metadata: { + │ │ "mcp.method": "initialize", + │ │ "mcp.request_id": 0, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Tokyo" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Tokyo", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.client.name": "trace-client", + │ │ "mcp.client.version": "1.0.0", + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── render_map [tool] + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "data": "", + │ │ "mimeType": "image/png", + │ │ "type": "image" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ └── get_alerts [tool] + │ output: { + │ "content": [ + │ { + │ "text": "Alert service unavailable", + │ "type": "text" + │ } + │ ], + │ "isError": true + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 3, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "Alert service unavailable" + └── mcp-lifecycle-operation + metadata: { + "operation": "lifecycle", + "testRunId": "" + } + metrics: { + "end": 0, + "start": 0 + } + context: { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + ├── initialize [function] + │ metadata: { + │ "mcp.method": "initialize", + │ "mcp.request_id": 0, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── ping [function] + │ metadata: { + │ "mcp.method": "ping", + │ "mcp.request_id": 100, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── tools/list [function] + │ metadata: { + │ "mcp.method": "tools/list", + │ "mcp.request_id": 101, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── acme/missing [function] + │ metadata: { + │ "mcp.method": "acme/missing", + │ "mcp.request_id": 102, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "MCP error -32601: Method not found" + ├── get_weather [tool] + │ input: { + │ "city": 42 + │ } + │ output: { + │ "content": [ + │ { + │ "text": "MCP error -32602: Input validation error: Invalid arguments for tool get_weather: Invalid input: expected string, received number at city", + │ "type": "text" + │ } + │ ], + │ "isError": true + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 103, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "MCP error -32602: Input validation error: Invalid arguments for tool get_weather: Invalid input: expected string, received number at city" + ├── wait_for_cancel [tool] + │ metadata: { + │ "mcp.cancelled": true, + │ "mcp.method": "tools/call", + │ "mcp.request_id": 104, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + └── acme/cancel [function] + metadata: { + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, + "mcp.session_id": "" + } + metrics: { + "end": 0, + "start": 0 + } diff --git a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.json b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.json new file mode 100644 index 000000000..15db820c3 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.json @@ -0,0 +1,461 @@ +{ + "span_tree": [ + { + "name": "mcp-instrumentation-root", + "type": "task", + "children": [ + { + "name": "mcp-http-session-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Vienna" + }, + "output": { + "content": [ + { + "text": "Sunny in Vienna", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Paris" + }, + "output": { + "content": [ + { + "text": "Sunny in Paris", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "resources/read", + "type": "function", + "children": [], + "input": { + "uri": "notes://forecast" + }, + "output": { + "contents": [ + { + "text": "Bring an umbrella.", + "uri": "notes://forecast" + } + ] + }, + "metadata": { + "mcp.method": "resources/read", + "mcp.request_id": 3, + "mcp.resource_uri": "notes://forecast", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "prompts/get", + "type": "function", + "children": [], + "input": { + "city": "Vienna" + }, + "output": { + "messages": [ + { + "content": { + "text": "What is the forecast for Vienna?", + "type": "text" + }, + "role": "user" + } + ] + }, + "metadata": { + "mcp.method": "prompts/get", + "mcp.prompt_name": "forecast", + "mcp.request_id": 4, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + } + ], + "metadata": { + "operation": "http-session", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + }, + { + "name": "mcp-in-memory-session-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Tokyo" + }, + "output": { + "content": [ + { + "text": "Sunny in Tokyo", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.client.name": "trace-client", + "mcp.client.version": "1.0.0", + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "render_map", + "type": "tool", + "children": [], + "output": { + "content": [ + { + "data": "", + "mimeType": "image/png", + "type": "image" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_alerts", + "type": "tool", + "children": [], + "output": { + "content": [ + { + "text": "Alert service unavailable", + "type": "text" + } + ], + "isError": true + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 3, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "Alert service unavailable" + } + ], + "metadata": { + "operation": "in-memory-session", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + }, + { + "name": "mcp-lifecycle-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "ping", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "ping", + "mcp.request_id": 100, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "tools/list", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "tools/list", + "mcp.request_id": 101, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "acme/missing", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "acme/missing", + "mcp.request_id": 102, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "MCP error -32601: Method not found" + }, + { + "name": "get_weather", + "type": "tool", + "children": [], + "input": { + "city": 42 + }, + "output": { + "content": [ + { + "text": "MCP error -32602: Input validation error: Invalid arguments for tool get_weather: [\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"city\"\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]", + "type": "text" + } + ], + "isError": true + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 103, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "MCP error -32602: Input validation error: Invalid arguments for tool get_weather: [\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"city\"\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]" + }, + { + "name": "wait_for_cancel", + "type": "tool", + "children": [], + "metadata": { + "mcp.cancelled": true, + "mcp.method": "tools/call", + "mcp.request_id": 104, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "acme/cancel", + "type": "function", + "children": [], + "metadata": { + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + } + ], + "metadata": { + "operation": "lifecycle", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "metadata": { + "scenario": "mcp-instrumentation", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ] +} diff --git a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.txt b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.txt new file mode 100644 index 000000000..899d4962b --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.txt @@ -0,0 +1,365 @@ +span_tree: +└── mcp-instrumentation-root [task] + metadata: { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + metrics: { + "end": 0, + "start": 0 + } + context: { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + ├── mcp-http-session-operation + │ metadata: { + │ "operation": "http-session", + │ "testRunId": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ context: { + │ "caller_filename": "", + │ "caller_functionname": "", + │ "caller_lineno": 0 + │ } + │ ├── initialize [function] + │ │ metadata: { + │ │ "mcp.method": "initialize", + │ │ "mcp.request_id": 0, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Vienna" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Vienna", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Paris" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Paris", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── resources/read [function] + │ │ input: { + │ │ "uri": "notes://forecast" + │ │ } + │ │ output: { + │ │ "contents": [ + │ │ { + │ │ "text": "Bring an umbrella.", + │ │ "uri": "notes://forecast" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "resources/read", + │ │ "mcp.request_id": 3, + │ │ "mcp.resource_uri": "notes://forecast", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ └── prompts/get [function] + │ input: { + │ "city": "Vienna" + │ } + │ output: { + │ "messages": [ + │ { + │ "content": { + │ "text": "What is the forecast for Vienna?", + │ "type": "text" + │ }, + │ "role": "user" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "prompts/get", + │ "mcp.prompt_name": "forecast", + │ "mcp.request_id": 4, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── mcp-in-memory-session-operation + │ metadata: { + │ "operation": "in-memory-session", + │ "testRunId": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ context: { + │ "caller_filename": "", + │ "caller_functionname": "", + │ "caller_lineno": 0 + │ } + │ ├── initialize [function] + │ │ metadata: { + │ │ "mcp.method": "initialize", + │ │ "mcp.request_id": 0, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Tokyo" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Tokyo", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.client.name": "trace-client", + │ │ "mcp.client.version": "1.0.0", + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── render_map [tool] + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "data": "", + │ │ "mimeType": "image/png", + │ │ "type": "image" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ └── get_alerts [tool] + │ output: { + │ "content": [ + │ { + │ "text": "Alert service unavailable", + │ "type": "text" + │ } + │ ], + │ "isError": true + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 3, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "Alert service unavailable" + └── mcp-lifecycle-operation + metadata: { + "operation": "lifecycle", + "testRunId": "" + } + metrics: { + "end": 0, + "start": 0 + } + context: { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + ├── initialize [function] + │ metadata: { + │ "mcp.method": "initialize", + │ "mcp.request_id": 0, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── ping [function] + │ metadata: { + │ "mcp.method": "ping", + │ "mcp.request_id": 100, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── tools/list [function] + │ metadata: { + │ "mcp.method": "tools/list", + │ "mcp.request_id": 101, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── acme/missing [function] + │ metadata: { + │ "mcp.method": "acme/missing", + │ "mcp.request_id": 102, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "MCP error -32601: Method not found" + ├── get_weather [tool] + │ input: { + │ "city": 42 + │ } + │ output: { + │ "content": [ + │ { + │ "text": "MCP error -32602: Input validation error: Invalid arguments for tool get_weather: [\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"city\"\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]", + │ "type": "text" + │ } + │ ], + │ "isError": true + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 103, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "MCP error -32602: Input validation error: Invalid arguments for tool get_weather: [\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"city\"\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]" + ├── wait_for_cancel [tool] + │ metadata: { + │ "mcp.cancelled": true, + │ "mcp.method": "tools/call", + │ "mcp.request_id": 104, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + └── acme/cancel [function] + metadata: { + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, + "mcp.session_id": "" + } + metrics: { + "end": 0, + "start": 0 + } diff --git a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.json b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.json new file mode 100644 index 000000000..1f244a9f3 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.json @@ -0,0 +1,461 @@ +{ + "span_tree": [ + { + "name": "mcp-instrumentation-root", + "type": "task", + "children": [ + { + "name": "mcp-http-session-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Vienna" + }, + "output": { + "content": [ + { + "text": "Sunny in Vienna", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Paris" + }, + "output": { + "content": [ + { + "text": "Sunny in Paris", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "resources/read", + "type": "function", + "children": [], + "input": { + "uri": "notes://forecast" + }, + "output": { + "contents": [ + { + "text": "Bring an umbrella.", + "uri": "notes://forecast" + } + ] + }, + "metadata": { + "mcp.method": "resources/read", + "mcp.request_id": 3, + "mcp.resource_uri": "notes://forecast", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "prompts/get", + "type": "function", + "children": [], + "input": { + "city": "Vienna" + }, + "output": { + "messages": [ + { + "content": { + "text": "What is the forecast for Vienna?", + "type": "text" + }, + "role": "user" + } + ] + }, + "metadata": { + "mcp.method": "prompts/get", + "mcp.prompt_name": "forecast", + "mcp.request_id": 4, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + } + ], + "metadata": { + "operation": "http-session", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + }, + { + "name": "mcp-in-memory-session-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Tokyo" + }, + "output": { + "content": [ + { + "text": "Sunny in Tokyo", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.client.name": "trace-client", + "mcp.client.version": "1.0.0", + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "render_map", + "type": "tool", + "children": [], + "output": { + "content": [ + { + "data": "", + "mimeType": "image/png", + "type": "image" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_alerts", + "type": "tool", + "children": [], + "output": { + "content": [ + { + "text": "Alert service unavailable", + "type": "text" + } + ], + "isError": true + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 3, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "Alert service unavailable" + } + ], + "metadata": { + "operation": "in-memory-session", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + }, + { + "name": "mcp-lifecycle-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "ping", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "ping", + "mcp.request_id": 100, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "tools/list", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "tools/list", + "mcp.request_id": 101, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "acme/missing", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "acme/missing", + "mcp.request_id": 102, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "MCP error -32601: Method not found" + }, + { + "name": "get_weather", + "type": "tool", + "children": [], + "input": { + "city": 42 + }, + "output": { + "content": [ + { + "text": "Input validation error: Invalid arguments for tool get_weather: city: Invalid input: expected string, received number", + "type": "text" + } + ], + "isError": true + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 103, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "Input validation error: Invalid arguments for tool get_weather: city: Invalid input: expected string, received number" + }, + { + "name": "wait_for_cancel", + "type": "tool", + "children": [], + "metadata": { + "mcp.cancelled": true, + "mcp.method": "tools/call", + "mcp.request_id": 104, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "acme/cancel", + "type": "function", + "children": [], + "metadata": { + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + } + ], + "metadata": { + "operation": "lifecycle", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "metadata": { + "scenario": "mcp-instrumentation", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ] +} diff --git a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.txt b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.txt new file mode 100644 index 000000000..3411882f1 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.txt @@ -0,0 +1,365 @@ +span_tree: +└── mcp-instrumentation-root [task] + metadata: { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + metrics: { + "end": 0, + "start": 0 + } + context: { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + ├── mcp-http-session-operation + │ metadata: { + │ "operation": "http-session", + │ "testRunId": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ context: { + │ "caller_filename": "", + │ "caller_functionname": "", + │ "caller_lineno": 0 + │ } + │ ├── initialize [function] + │ │ metadata: { + │ │ "mcp.method": "initialize", + │ │ "mcp.request_id": 0, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Vienna" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Vienna", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Paris" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Paris", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── resources/read [function] + │ │ input: { + │ │ "uri": "notes://forecast" + │ │ } + │ │ output: { + │ │ "contents": [ + │ │ { + │ │ "text": "Bring an umbrella.", + │ │ "uri": "notes://forecast" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "resources/read", + │ │ "mcp.request_id": 3, + │ │ "mcp.resource_uri": "notes://forecast", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ └── prompts/get [function] + │ input: { + │ "city": "Vienna" + │ } + │ output: { + │ "messages": [ + │ { + │ "content": { + │ "text": "What is the forecast for Vienna?", + │ "type": "text" + │ }, + │ "role": "user" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "prompts/get", + │ "mcp.prompt_name": "forecast", + │ "mcp.request_id": 4, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── mcp-in-memory-session-operation + │ metadata: { + │ "operation": "in-memory-session", + │ "testRunId": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ context: { + │ "caller_filename": "", + │ "caller_functionname": "", + │ "caller_lineno": 0 + │ } + │ ├── initialize [function] + │ │ metadata: { + │ │ "mcp.method": "initialize", + │ │ "mcp.request_id": 0, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Tokyo" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Tokyo", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.client.name": "trace-client", + │ │ "mcp.client.version": "1.0.0", + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── render_map [tool] + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "data": "", + │ │ "mimeType": "image/png", + │ │ "type": "image" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ └── get_alerts [tool] + │ output: { + │ "content": [ + │ { + │ "text": "Alert service unavailable", + │ "type": "text" + │ } + │ ], + │ "isError": true + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 3, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "Alert service unavailable" + └── mcp-lifecycle-operation + metadata: { + "operation": "lifecycle", + "testRunId": "" + } + metrics: { + "end": 0, + "start": 0 + } + context: { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + ├── initialize [function] + │ metadata: { + │ "mcp.method": "initialize", + │ "mcp.request_id": 0, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── ping [function] + │ metadata: { + │ "mcp.method": "ping", + │ "mcp.request_id": 100, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── tools/list [function] + │ metadata: { + │ "mcp.method": "tools/list", + │ "mcp.request_id": 101, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── acme/missing [function] + │ metadata: { + │ "mcp.method": "acme/missing", + │ "mcp.request_id": 102, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "MCP error -32601: Method not found" + ├── get_weather [tool] + │ input: { + │ "city": 42 + │ } + │ output: { + │ "content": [ + │ { + │ "text": "Input validation error: Invalid arguments for tool get_weather: city: Invalid input: expected string, received number", + │ "type": "text" + │ } + │ ], + │ "isError": true + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 103, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "Input validation error: Invalid arguments for tool get_weather: city: Invalid input: expected string, received number" + ├── wait_for_cancel [tool] + │ metadata: { + │ "mcp.cancelled": true, + │ "mcp.method": "tools/call", + │ "mcp.request_id": 104, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + └── acme/cancel [function] + metadata: { + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, + "mcp.session_id": "" + } + metrics: { + "end": 0, + "start": 0 + } diff --git a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.json b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.json new file mode 100644 index 000000000..1f244a9f3 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.json @@ -0,0 +1,461 @@ +{ + "span_tree": [ + { + "name": "mcp-instrumentation-root", + "type": "task", + "children": [ + { + "name": "mcp-http-session-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Vienna" + }, + "output": { + "content": [ + { + "text": "Sunny in Vienna", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Paris" + }, + "output": { + "content": [ + { + "text": "Sunny in Paris", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "resources/read", + "type": "function", + "children": [], + "input": { + "uri": "notes://forecast" + }, + "output": { + "contents": [ + { + "text": "Bring an umbrella.", + "uri": "notes://forecast" + } + ] + }, + "metadata": { + "mcp.method": "resources/read", + "mcp.request_id": 3, + "mcp.resource_uri": "notes://forecast", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "prompts/get", + "type": "function", + "children": [], + "input": { + "city": "Vienna" + }, + "output": { + "messages": [ + { + "content": { + "text": "What is the forecast for Vienna?", + "type": "text" + }, + "role": "user" + } + ] + }, + "metadata": { + "mcp.method": "prompts/get", + "mcp.prompt_name": "forecast", + "mcp.request_id": 4, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + } + ], + "metadata": { + "operation": "http-session", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + }, + { + "name": "mcp-in-memory-session-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "input": { + "city": "Tokyo" + }, + "output": { + "content": [ + { + "text": "Sunny in Tokyo", + "type": "text" + } + ], + "structuredContent": { + "_meta": { + "application": "preserved" + } + } + }, + "metadata": { + "mcp.client.name": "trace-client", + "mcp.client.version": "1.0.0", + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.server.name": "braintrust-e2e-weather", + "mcp.server.version": "1.0.0", + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "render_map", + "type": "tool", + "children": [], + "output": { + "content": [ + { + "data": "", + "mimeType": "image/png", + "type": "image" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "get_alerts", + "type": "tool", + "children": [], + "output": { + "content": [ + { + "text": "Alert service unavailable", + "type": "text" + } + ], + "isError": true + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 3, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "Alert service unavailable" + } + ], + "metadata": { + "operation": "in-memory-session", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + }, + { + "name": "mcp-lifecycle-operation", + "children": [ + { + "name": "initialize", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "initialize", + "mcp.request_id": 0, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "ping", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "ping", + "mcp.request_id": 100, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "tools/list", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "tools/list", + "mcp.request_id": 101, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "acme/missing", + "type": "function", + "children": [], + "metadata": { + "mcp.method": "acme/missing", + "mcp.request_id": 102, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "MCP error -32601: Method not found" + }, + { + "name": "get_weather", + "type": "tool", + "children": [], + "input": { + "city": 42 + }, + "output": { + "content": [ + { + "text": "Input validation error: Invalid arguments for tool get_weather: city: Invalid input: expected string, received number", + "type": "text" + } + ], + "isError": true + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 103, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "error": "Input validation error: Invalid arguments for tool get_weather: city: Invalid input: expected string, received number" + }, + { + "name": "wait_for_cancel", + "type": "tool", + "children": [], + "metadata": { + "mcp.cancelled": true, + "mcp.method": "tools/call", + "mcp.request_id": 104, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + }, + { + "name": "acme/cancel", + "type": "function", + "children": [], + "metadata": { + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, + "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 + } + } + ], + "metadata": { + "operation": "lifecycle", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ], + "metadata": { + "scenario": "mcp-instrumentation", + "testRunId": "" + }, + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + } + ] +} diff --git a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.txt b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.txt new file mode 100644 index 000000000..3411882f1 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.txt @@ -0,0 +1,365 @@ +span_tree: +└── mcp-instrumentation-root [task] + metadata: { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + metrics: { + "end": 0, + "start": 0 + } + context: { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + ├── mcp-http-session-operation + │ metadata: { + │ "operation": "http-session", + │ "testRunId": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ context: { + │ "caller_filename": "", + │ "caller_functionname": "", + │ "caller_lineno": 0 + │ } + │ ├── initialize [function] + │ │ metadata: { + │ │ "mcp.method": "initialize", + │ │ "mcp.request_id": 0, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Vienna" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Vienna", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Paris" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Paris", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── resources/read [function] + │ │ input: { + │ │ "uri": "notes://forecast" + │ │ } + │ │ output: { + │ │ "contents": [ + │ │ { + │ │ "text": "Bring an umbrella.", + │ │ "uri": "notes://forecast" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "resources/read", + │ │ "mcp.request_id": 3, + │ │ "mcp.resource_uri": "notes://forecast", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ └── prompts/get [function] + │ input: { + │ "city": "Vienna" + │ } + │ output: { + │ "messages": [ + │ { + │ "content": { + │ "text": "What is the forecast for Vienna?", + │ "type": "text" + │ }, + │ "role": "user" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "prompts/get", + │ "mcp.prompt_name": "forecast", + │ "mcp.request_id": 4, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── mcp-in-memory-session-operation + │ metadata: { + │ "operation": "in-memory-session", + │ "testRunId": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ context: { + │ "caller_filename": "", + │ "caller_functionname": "", + │ "caller_lineno": 0 + │ } + │ ├── initialize [function] + │ │ metadata: { + │ │ "mcp.method": "initialize", + │ │ "mcp.request_id": 0, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Tokyo" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Tokyo", + │ │ "type": "text" + │ │ } + │ │ ], + │ │ "structuredContent": { + │ │ "_meta": { + │ │ "application": "preserved" + │ │ } + │ │ } + │ │ } + │ │ metadata: { + │ │ "mcp.client.name": "trace-client", + │ │ "mcp.client.version": "1.0.0", + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.server.name": "braintrust-e2e-weather", + │ │ "mcp.server.version": "1.0.0", + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ └── weather-lookup + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ │ context: { + │ │ "caller_filename": "", + │ │ "caller_functionname": "", + │ │ "caller_lineno": 0 + │ │ } + │ ├── render_map [tool] + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "data": "", + │ │ "mimeType": "image/png", + │ │ "type": "image" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.session_id": "" + │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } + │ └── get_alerts [tool] + │ output: { + │ "content": [ + │ { + │ "text": "Alert service unavailable", + │ "type": "text" + │ } + │ ], + │ "isError": true + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 3, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "Alert service unavailable" + └── mcp-lifecycle-operation + metadata: { + "operation": "lifecycle", + "testRunId": "" + } + metrics: { + "end": 0, + "start": 0 + } + context: { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } + ├── initialize [function] + │ metadata: { + │ "mcp.method": "initialize", + │ "mcp.request_id": 0, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── ping [function] + │ metadata: { + │ "mcp.method": "ping", + │ "mcp.request_id": 100, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── tools/list [function] + │ metadata: { + │ "mcp.method": "tools/list", + │ "mcp.request_id": 101, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + ├── acme/missing [function] + │ metadata: { + │ "mcp.method": "acme/missing", + │ "mcp.request_id": 102, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "MCP error -32601: Method not found" + ├── get_weather [tool] + │ input: { + │ "city": 42 + │ } + │ output: { + │ "content": [ + │ { + │ "text": "Input validation error: Invalid arguments for tool get_weather: city: Invalid input: expected string, received number", + │ "type": "text" + │ } + │ ], + │ "isError": true + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 103, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + │ error: "Input validation error: Invalid arguments for tool get_weather: city: Invalid input: expected string, received number" + ├── wait_for_cancel [tool] + │ metadata: { + │ "mcp.cancelled": true, + │ "mcp.method": "tools/call", + │ "mcp.request_id": 104, + │ "mcp.session_id": "" + │ } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + └── acme/cancel [function] + metadata: { + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, + "mcp.session_id": "" + } + metrics: { + "end": 0, + "start": 0 + } diff --git a/e2e/scenarios/mcp-instrumentation/assertions.ts b/e2e/scenarios/mcp-instrumentation/assertions.ts new file mode 100644 index 000000000..ddd906c79 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/assertions.ts @@ -0,0 +1,242 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import type { CapturedLogEvent } from "../../helpers/mock-braintrust-server"; +import { resolveFileSnapshotPath } from "../../helpers/file-snapshot"; +import { + withScenarioHarness, + type ScenarioRunContext, +} from "../../helpers/scenario-harness"; +import { matchSpanTreeSnapshot, spanTreeFields } from "../../helpers/span-tree"; +import { + findChildSpans, + findLatestSpan, + spanInstrumentationName, +} from "../../helpers/trace-selectors"; +import { ROOT_NAME, SCENARIO_NAME } from "./scenario.impl.mjs"; + +type RunMCPScenario = (harness: { + runNodeScenarioDir: (options: { + entry: string; + env: Record; + nodeArgs: string[]; + runContext?: ScenarioRunContext; + scenarioDir: string; + }) => Promise; + runScenarioDir: (options: { + entry: string; + env: Record; + runContext?: ScenarioRunContext; + scenarioDir: string; + }) => Promise; +}) => Promise; + +const SESSIONS = [ + { + operationName: "mcp-http-session-operation", + spans: ["get_weather", "get_weather", "resources/read", "prompts/get"], + }, + { + operationName: "mcp-in-memory-session-operation", + spans: ["get_weather", "render_map", "get_alerts"], + }, +] as const; + +function mcpSpans( + events: CapturedLogEvent[], + operationName: string, +): CapturedLogEvent[] { + const operation = findLatestSpan(events, operationName); + return [ + ...new Set(SESSIONS.flatMap(({ spans }) => spans as readonly string[])), + ] + .flatMap((name) => findChildSpans(events, name, operation?.span.id)) + .sort( + (left, right) => + Number(left.metrics?.start ?? 0) - Number(right.metrics?.start ?? 0), + ); +} + +export function defineMCPInstrumentationAssertions(options: { + name: string; + runScenario: RunMCPScenario; + snapshotName: string; + testFileUrl: string; +}): void { + const spanSnapshotPath = resolveFileSnapshotPath( + options.testFileUrl, + `${options.snapshotName}.span-tree.json`, + ); + + describe(options.name, () => { + let events: CapturedLogEvent[] = []; + + beforeAll(async () => { + await withScenarioHarness(async (harness) => { + await options.runScenario(harness); + const allEvents = harness.events(); + const root = findLatestSpan(allEvents, ROOT_NAME); + events = allEvents.filter( + (event) => event.span.rootId === root?.span.rootId, + ); + }); + }); + + test("captures the scenario root span", () => { + const root = findLatestSpan(events, ROOT_NAME); + expect(root).toBeDefined(); + expect(root?.row.metadata).toMatchObject({ + scenario: SCENARIO_NAME, + }); + }); + + test.each(SESSIONS)( + "correlates requests in $operationName with one session ID", + ({ operationName, spans }) => { + const requests = mcpSpans(events, operationName); + + expect(requests.map((span) => span.span.name)).toEqual(spans); + const sessionIds = new Set( + requests.map((span) => span.row.metadata?.["mcp.session_id"]), + ); + expect(sessionIds.size).toBe(1); + expect([...sessionIds][0]).toEqual(expect.any(String)); + for (const span of requests) { + expect(spanInstrumentationName(span)).toBe("mcp"); + } + }, + ); + + test("uses a separate session ID per connection", () => { + const [httpSession, inMemorySession] = SESSIONS.map( + ({ operationName }) => + mcpSpans(events, operationName)[0]?.row.metadata?.["mcp.session_id"], + ); + expect(httpSession).not.toBe(inMemorySession); + }); + + test("captures tool calls", () => { + const operation = findLatestSpan( + events, + "mcp-in-memory-session-operation", + ); + const [weather] = findChildSpans( + events, + "get_weather", + operation?.span.id, + ); + const [alerts] = findChildSpans(events, "get_alerts", operation?.span.id); + const [map] = findChildSpans(events, "render_map", operation?.span.id); + + expect(weather?.span.type).toBe("tool"); + expect(weather?.input).toEqual({ city: "Tokyo" }); + expect(weather?.output).toMatchObject({ + content: [{ type: "text", text: "Sunny in Tokyo" }], + }); + expect(weather?.row.metadata).toMatchObject({ + "mcp.method": "tools/call", + }); + expect( + findChildSpans(events, "weather-lookup", weather?.span.id), + ).toHaveLength(1); + expect(map?.output).toMatchObject({ + content: [{ type: "image", data: "", mimeType: "image/png" }], + }); + expect(alerts?.row.error).toContain("Alert service unavailable"); + }); + + test("captures dispatch failures, discovery, and honored cancellation", () => { + const operation = findLatestSpan(events, "mcp-lifecycle-operation"); + const [ping] = findChildSpans(events, "ping", operation?.span.id); + const [discovery] = findChildSpans( + events, + "tools/list", + operation?.span.id, + ); + const [missing] = findChildSpans( + events, + "acme/missing", + operation?.span.id, + ); + const [invalid] = findChildSpans( + events, + "get_weather", + operation?.span.id, + ); + const [cancelled] = findChildSpans( + events, + "wait_for_cancel", + operation?.span.id, + ); + expect(ping?.span.type).toBe("function"); + expect(discovery?.input).toBeUndefined(); + expect(discovery?.output).toBeUndefined(); + expect(missing?.row.error).toContain("Method not found"); + expect(invalid?.row.error).toEqual(expect.any(String)); + expect(cancelled?.row.metadata?.["mcp.cancelled"]).toBe(true); + expect(cancelled?.row.error).toBeUndefined(); + expect(cancelled?.output).toBeUndefined(); + const [fallback] = findChildSpans( + events, + "acme/cancel", + operation?.span.id, + ); + expect(fallback?.row.metadata?.["mcp.cancelled"]).toBe(true); + expect(fallback?.row.error).toBeUndefined(); + expect(fallback?.output).toBeUndefined(); + expect( + findChildSpans(events, "notifications/cancelled", operation?.span.id), + ).toHaveLength(0); + expect(JSON.stringify(events)).not.toContain( + "private cancellation reason", + ); + }); + + test("translates protocol metadata and preserves application metadata", () => { + const operation = findLatestSpan( + events, + "mcp-in-memory-session-operation", + ); + const [weather] = findChildSpans( + events, + "get_weather", + operation?.span.id, + ); + expect(weather?.row.metadata).toMatchObject({ + "mcp.client.name": "trace-client", + "mcp.client.version": "1.0.0", + "mcp.server.name": "braintrust-e2e-weather", + }); + expect(weather?.output).toMatchObject({ + structuredContent: { _meta: { application: "preserved" } }, + }); + expect(weather?.output).not.toHaveProperty("_meta"); + expect(weather?.output).not.toHaveProperty("content.0._meta"); + }); + + test("matches the span tree snapshot", async ({ expect }) => { + await matchSpanTreeSnapshot( + events.map((event) => ({ + event, + fields: { + ...spanTreeFields(event), + // Manual and generated wrappers have different caller stacks. + // Keep the context contract while normalizing those locations. + context: event.row.context && { + ...event.row.context, + ...(event.row.context.caller_filename !== undefined + ? { caller_filename: "" } + : {}), + ...(event.row.context.caller_functionname !== undefined + ? { caller_functionname: "" } + : {}), + }, + metrics: event.metrics, + }, + })), + spanSnapshotPath, + { + snapshotExpect: expect, + }, + ); + }); + }); +} diff --git a/e2e/scenarios/mcp-instrumentation/package.json b/e2e/scenarios/mcp-instrumentation/package.json new file mode 100644 index 000000000..d2024fa1e --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/package.json @@ -0,0 +1,30 @@ +{ + "name": "@braintrust/e2e-mcp-instrumentation", + "private": true, + "braintrustScenario": { + "bump": { + "dependencies": { + "mcp-sdk-v1-latest": { + "package": "@modelcontextprotocol/sdk", + "range": "1" + }, + "mcp-server-v2-latest": { + "package": "@modelcontextprotocol/server", + "range": "2" + }, + "mcp-client-v2-latest": { + "package": "@modelcontextprotocol/client", + "range": "2" + } + } + } + }, + "dependencies": { + "mcp-client-v2-latest": "npm:@modelcontextprotocol/client@2.4.0", + "mcp-sdk-v1": "npm:@modelcontextprotocol/sdk@1.25.0", + "mcp-sdk-v1-latest": "npm:@modelcontextprotocol/sdk@1.32.2", + "mcp-server-v2": "npm:@modelcontextprotocol/server@2.0.0", + "mcp-server-v2-latest": "npm:@modelcontextprotocol/server@2.4.0", + "zod": "4.3.6" + } +} diff --git a/e2e/scenarios/mcp-instrumentation/pnpm-lock.yaml b/e2e/scenarios/mcp-instrumentation/pnpm-lock.yaml new file mode 100644 index 000000000..7b950a68d --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/pnpm-lock.yaml @@ -0,0 +1,901 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + mcp-client-v2-latest: + specifier: npm:@modelcontextprotocol/client@2.4.0 + version: '@modelcontextprotocol/client@2.4.0' + mcp-sdk-v1: + specifier: npm:@modelcontextprotocol/sdk@1.25.0 + version: '@modelcontextprotocol/sdk@1.25.0(hono@4.13.14)(zod@4.3.6)' + mcp-sdk-v1-latest: + specifier: npm:@modelcontextprotocol/sdk@1.32.2 + version: '@modelcontextprotocol/sdk@1.32.2(zod@4.3.6)' + mcp-server-v2: + specifier: npm:@modelcontextprotocol/server@2.0.0 + version: '@modelcontextprotocol/server@2.0.0' + mcp-server-v2-latest: + specifier: npm:@modelcontextprotocol/server@2.4.0 + version: '@modelcontextprotocol/server@2.4.0' + zod: + specifier: 4.3.6 + version: 4.3.6 + +packages: + + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + + '@modelcontextprotocol/client@2.4.0': + resolution: {integrity: sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.4.0': + resolution: {integrity: sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==} + engines: {node: '>=20'} + + '@modelcontextprotocol/sdk@1.25.0': + resolution: {integrity: sha512-z0Zhn/LmQ3yz91dEfd5QgS7DpSjA4pk+3z2++zKgn5L6iDFM9QapsVoAQSbKLvlrFsZk9+ru6yHHWNq2lCYJKQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@modelcontextprotocol/sdk@1.32.2': + resolution: {integrity: sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.4.0': + resolution: {integrity: sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==} + engines: {node: '>=20'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.13.14: + resolution: {integrity: sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.5: + resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.2: + resolution: {integrity: sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.17.0: + resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@hono/node-server@1.19.17(hono@4.13.14)': + dependencies: + hono: 4.13.14 + + '@hono/node-server@2.1.5(hono@4.13.14)': + dependencies: + hono: 4.13.14 + + '@modelcontextprotocol/client@2.4.0': + dependencies: + '@modelcontextprotocol/core': 2.4.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.13 + pkce-challenge: 5.0.1 + zod: 4.3.6 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.3.6 + + '@modelcontextprotocol/core@2.4.0': + dependencies: + zod: 4.3.6 + + '@modelcontextprotocol/sdk@1.25.0(hono@4.13.14)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.13.14) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 7.5.1(express@5.3.0) + jose: 6.2.13 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - hono + - supports-color + + '@modelcontextprotocol/sdk@1.32.2(zod@4.3.6)': + dependencies: + '@hono/node-server': 2.1.5(hono@4.13.14) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.14 + jose: 6.2.13 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.3.6 + + '@modelcontextprotocol/server@2.4.0': + dependencies: + '@modelcontextprotocol/core': 2.4.0 + zod: 4.3.6 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + body-parser@2.4.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.2 + qs: 6.17.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + express-rate-limit@7.5.1(express@5.3.0): + dependencies: + express: 5.3.0 + + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.5 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.4.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.2 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.17.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.9: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.2 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.13.14: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.5: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.13: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.2: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.17.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.2 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/e2e/scenarios/mcp-instrumentation/scenario.cjs.mjs b/e2e/scenarios/mcp-instrumentation/scenario.cjs.mjs new file mode 100644 index 000000000..f4d857ec1 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/scenario.cjs.mjs @@ -0,0 +1,8 @@ +import { createRequire } from "node:module"; +import { runMain } from "../../helpers/provider-runtime.mjs"; +import { loadMCPModules, runAutoMCPInstrumentation } from "./scenario.impl.mjs"; + +const require = createRequire(import.meta.url); +const mcp = await loadMCPModules(require); + +runMain(async () => runAutoMCPInstrumentation(mcp)); diff --git a/e2e/scenarios/mcp-instrumentation/scenario.cjs.ts b/e2e/scenarios/mcp-instrumentation/scenario.cjs.ts new file mode 100644 index 000000000..9d466aa19 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/scenario.cjs.ts @@ -0,0 +1,13 @@ +import { createRequire } from "node:module"; +import { runMain } from "../../helpers/scenario-runtime"; +import { + loadMCPModules, + runWrappedMCPInstrumentation, +} from "./scenario.impl.mjs"; + +const require = createRequire(import.meta.url); + +runMain(async () => { + const mcp = await loadMCPModules(require); + await runWrappedMCPInstrumentation(mcp); +}); diff --git a/e2e/scenarios/mcp-instrumentation/scenario.impl.mjs b/e2e/scenarios/mcp-instrumentation/scenario.impl.mjs new file mode 100644 index 000000000..7866a2981 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/scenario.impl.mjs @@ -0,0 +1,330 @@ +import { randomUUID } from "node:crypto"; +import { traced, wrapMCPServer } from "braintrust"; +import { z } from "zod"; +import { MINIMAL_PNG_BASE64 } from "../../helpers/media-fixtures.mjs"; +import { + runOperation, + runTracedScenario, +} from "../../helpers/provider-runtime.mjs"; + +export const ROOT_NAME = "mcp-instrumentation-root"; +export const SCENARIO_NAME = "mcp-instrumentation"; + +const DEFAULT_PACKAGES = { + v1: { server: "mcp-sdk-v1-latest" }, + v2: { client: "mcp-client-v2-latest", server: "mcp-server-v2-latest" }, +}; + +/** Load the server and client SDK surface through `import()` or `require()`. */ +export async function loadMCPModules(load) { + const line = process.env.MCP_SDK_LINE ?? "v1"; + const serverPackage = + process.env.MCP_SERVER_PACKAGE_NAME ?? DEFAULT_PACKAGES[line].server; + + if (line === "v1") { + const [mcp, http, client, clientHttp, inMemory] = await Promise.all( + [ + "server/mcp.js", + "server/webStandardStreamableHttp.js", + "client/index.js", + "client/streamableHttp.js", + "inMemory.js", + ].map((path) => load(`${serverPackage}/${path}`)), + ); + return { + Client: client.Client, + InMemoryTransport: inMemory.InMemoryTransport, + McpServer: mcp.McpServer, + // v1 accepts Zod raw shapes for tool and prompt arguments. + schema: (shape) => shape, + StreamableHTTPClientTransport: clientHttp.StreamableHTTPClientTransport, + WebStandardStreamableHTTPServerTransport: + http.WebStandardStreamableHTTPServerTransport, + }; + } + + const [server, client] = await Promise.all([ + load(serverPackage), + load(DEFAULT_PACKAGES.v2.client), + ]); + return { + Client: client.Client, + InMemoryTransport: server.InMemoryTransport, + McpServer: server.McpServer, + schema: (shape) => z.object(shape), + StreamableHTTPClientTransport: client.StreamableHTTPClientTransport, + WebStandardStreamableHTTPServerTransport: + server.WebStandardStreamableHTTPServerTransport, + }; +} + +function createServer(mcp, decorateServer) { + const baseServer = new mcp.McpServer({ + name: "braintrust-e2e-weather", + version: "1.0.0", + }); + const server = decorateServer ? decorateServer(baseServer) : baseServer; + + server.registerTool( + "get_weather", + { + description: "Get the current weather for a city", + inputSchema: mcp.schema({ city: z.string() }), + }, + async ({ city }) => + traced( + async () => ({ + content: [ + { + type: "text", + text: `Sunny in ${city}`, + _meta: { private: "omitted" }, + }, + ], + structuredContent: { _meta: { application: "preserved" } }, + _meta: { + "io.modelcontextprotocol/serverInfo": { + name: "braintrust-e2e-weather", + version: "1.0.0", + }, + private: "omitted", + }, + }), + { name: "weather-lookup" }, + ), + ); + server.registerTool( + "render_map", + { description: "Render a weather map" }, + async () => ({ + content: [ + { type: "image", data: MINIMAL_PNG_BASE64, mimeType: "image/png" }, + ], + }), + ); + server.registerTool( + "get_alerts", + { description: "Get weather alerts" }, + async () => { + throw new Error("Alert service unavailable"); + }, + ); + server.registerResource( + "forecast-notes", + "notes://forecast", + { mimeType: "text/plain" }, + async (uri) => ({ + contents: [{ uri: uri.href, text: "Bring an umbrella." }], + }), + ); + server.registerPrompt( + "forecast", + { + description: "Ask for a forecast", + argsSchema: mcp.schema({ city: z.string() }), + }, + ({ city }) => ({ + messages: [ + { + role: "user", + content: { type: "text", text: `What is the forecast for ${city}?` }, + }, + ], + }), + ); + + return baseServer; +} + +function createClient(mcp) { + return new mcp.Client({ name: "braintrust-e2e-client", version: "1.0.0" }); +} + +async function runHttpSession(mcp, decorateServer) { + const server = createServer(mcp, decorateServer); + const serverTransport = new mcp.WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + sessionIdGenerator: () => randomUUID(), + }); + await server.connect(serverTransport); + + // Serve requests in-process so server spans share the client's trace. + const client = createClient(mcp); + await client.connect( + new mcp.StreamableHTTPClientTransport(new URL("http://mcp.test/mcp"), { + fetch: (url, init) => + serverTransport.handleRequest(new Request(url, init)), + }), + ); + + await client.callTool({ name: "get_weather", arguments: { city: "Vienna" } }); + await client.callTool({ name: "get_weather", arguments: { city: "Paris" } }); + await client.readResource({ uri: "notes://forecast" }); + await client.getPrompt({ name: "forecast", arguments: { city: "Vienna" } }); + + await client.close(); + await server.close(); +} + +async function runInMemorySession(mcp, decorateServer) { + const server = createServer(mcp, decorateServer); + const [clientTransport, serverTransport] = + mcp.InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + const client = createClient(mcp); + await client.connect(clientTransport); + + await client.callTool({ + name: "get_weather", + arguments: { city: "Tokyo" }, + _meta: { + "io.modelcontextprotocol/clientInfo": { + name: "trace-client", + version: "1.0.0", + private: "omitted", + }, + private: "omitted", + }, + }); + await client.callTool({ name: "render_map", arguments: {} }); + await client.callTool({ name: "get_alerts", arguments: {} }); + + await client.close(); + await server.close(); +} + +async function runLifecycleSession(mcp, decorateServer) { + const server = createServer(mcp, decorateServer); + let handlerStarted; + let handlerCancelled; + const started = new Promise((resolve) => { + handlerStarted = resolve; + }); + const cancelled = new Promise((resolve) => { + handlerCancelled = resolve; + }); + server.registerTool( + "wait_for_cancel", + { description: "Wait for cancellation", inputSchema: mcp.schema({}) }, + async (_args, extra) => { + handlerStarted(); + const signal = extra.mcpReq?.signal ?? extra.signal; + await new Promise((resolve) => + signal.addEventListener("abort", resolve, { once: true }), + ); + handlerCancelled(); + return { content: [] }; + }, + ); + const [clientTransport, serverTransport] = + mcp.InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = createClient(mcp); + await client.connect(clientTransport); + + // Exercise actual protocol dispatch and validation with raw wire requests, + // including requests rejected before a registered handler can run. + const responses = new Map(); + clientTransport.onmessage = (message) => { + responses.get(message.id)?.(message); + }; + for (const request of [ + { id: 100, method: "ping" }, + { id: 101, method: "tools/list" }, + { id: 102, method: "acme/missing" }, + { + id: 103, + method: "tools/call", + params: { name: "get_weather", arguments: { city: 42 } }, + }, + ]) { + const response = new Promise((resolve) => + responses.set(request.id, resolve), + ); + await clientTransport.send({ jsonrpc: "2.0", ...request }); + await response; + responses.delete(request.id); + } + await clientTransport.send({ + jsonrpc: "2.0", + id: 104, + method: "tools/call", + params: { name: "wait_for_cancel", arguments: {} }, + }); + await started; + await clientTransport.send({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: 104, reason: "private cancellation reason" }, + }); + await cancelled; + // Wait for the handler's settlement before closing its connection. + await new Promise((resolve) => setImmediate(resolve)); + + const fallbackStarted = new Promise((resolve) => { + handlerStarted = resolve; + }); + const fallbackCancelled = new Promise((resolve) => { + handlerCancelled = resolve; + }); + // Direct assignments after connect must receive the same lifecycle handling. + server.server.fallbackRequestHandler = async (_request, extra) => { + handlerStarted(); + const signal = extra.mcpReq?.signal ?? extra.signal; + await new Promise((resolve) => + signal.addEventListener("abort", resolve, { once: true }), + ); + handlerCancelled(); + return {}; + }; + await clientTransport.send({ + jsonrpc: "2.0", + id: 105, + method: "acme/cancel", + }); + await fallbackStarted; + await clientTransport.send({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: 105, reason: "private cancellation reason" }, + }); + await fallbackCancelled; + await new Promise((resolve) => setImmediate(resolve)); + await client.close(); + await server.close(); +} + +export async function runMCPInstrumentationScenario( + mcp, + { decorateServer } = {}, +) { + await runTracedScenario({ + callback: async () => { + await runOperation("mcp-http-session-operation", "http-session", () => + runHttpSession(mcp, decorateServer), + ); + await runOperation( + "mcp-in-memory-session-operation", + "in-memory-session", + () => runInMemorySession(mcp, decorateServer), + ); + await runOperation("mcp-lifecycle-operation", "lifecycle", () => + runLifecycleSession(mcp, decorateServer), + ); + }, + metadata: { + scenario: SCENARIO_NAME, + }, + projectNameBase: "e2e-mcp-instrumentation", + rootName: ROOT_NAME, + }); +} + +export async function runWrappedMCPInstrumentation(mcp) { + await runMCPInstrumentationScenario(mcp, { decorateServer: wrapMCPServer }); +} + +export async function runAutoMCPInstrumentation(mcp) { + await runMCPInstrumentationScenario(mcp); +} diff --git a/e2e/scenarios/mcp-instrumentation/scenario.mjs b/e2e/scenarios/mcp-instrumentation/scenario.mjs new file mode 100644 index 000000000..ea8f7e415 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/scenario.mjs @@ -0,0 +1,6 @@ +import { runMain } from "../../helpers/provider-runtime.mjs"; +import { loadMCPModules, runAutoMCPInstrumentation } from "./scenario.impl.mjs"; + +const mcp = await loadMCPModules((specifier) => import(specifier)); + +runMain(async () => runAutoMCPInstrumentation(mcp)); diff --git a/e2e/scenarios/mcp-instrumentation/scenario.test.ts b/e2e/scenarios/mcp-instrumentation/scenario.test.ts new file mode 100644 index 000000000..1bf26dd88 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/scenario.test.ts @@ -0,0 +1,97 @@ +import { describe } from "vitest"; +import { describeVariant } from "../../helpers/describe-variant"; +import { + prepareScenarioDir, + readInstalledPackageVersion, + resolveScenarioDir, +} from "../../helpers/scenario-harness"; +import { defineMCPInstrumentationAssertions } from "./assertions"; + +const originalScenarioDir = resolveScenarioDir(import.meta.url); +const scenarioDir = await prepareScenarioDir({ + scenarioDir: originalScenarioDir, +}); + +const scenarios = await Promise.all( + [ + { + autoEntry: "scenario.cjs.mjs", + dependencyName: "mcp-sdk-v1", + line: "v1", + snapshotName: "mcp-sdk-v1", + wrapperEntry: "scenario.cjs.ts", + }, + { + autoEntry: "scenario.mjs", + dependencyName: "mcp-sdk-v1-latest", + line: "v1", + snapshotName: "mcp-sdk-v1-latest", + wrapperEntry: "scenario.ts", + }, + { + autoEntry: "scenario.cjs.mjs", + dependencyName: "mcp-server-v2", + line: "v2", + snapshotName: "mcp-server-v2", + wrapperEntry: "scenario.cjs.ts", + }, + { + autoEntry: "scenario.mjs", + dependencyName: "mcp-server-v2-latest", + line: "v2", + snapshotName: "mcp-server-v2-latest", + wrapperEntry: "scenario.ts", + }, + ].map(async (scenario) => ({ + ...scenario, + version: await readInstalledPackageVersion( + scenarioDir, + scenario.dependencyName, + ), + })), +); + +describe.concurrent("variants", () => { + for (const scenario of scenarios) { + describeVariant(`mcp ${scenario.line} ${scenario.version}`, () => { + const env = { + MCP_SDK_LINE: scenario.line, + MCP_SERVER_PACKAGE_NAME: scenario.dependencyName, + }; + const runContext = { + cassette: false, + originalScenarioDir, + variantKey: scenario.snapshotName, + }; + + defineMCPInstrumentationAssertions({ + name: "wrapped instrumentation", + runScenario: async ({ runScenarioDir }) => { + await runScenarioDir({ + entry: scenario.wrapperEntry, + env, + runContext, + scenarioDir, + }); + }, + snapshotName: scenario.snapshotName, + testFileUrl: import.meta.url, + }); + + defineMCPInstrumentationAssertions({ + name: "auto-hook instrumentation", + runScenario: async ({ runNodeScenarioDir }) => { + await runNodeScenarioDir({ + entry: scenario.autoEntry, + env, + nodeArgs: ["--import", "braintrust/hook.mjs"], + runContext, + scenarioDir, + }); + }, + snapshotName: scenario.snapshotName, + testFileUrl: import.meta.url, + }); + }); + } +}); diff --git a/e2e/scenarios/mcp-instrumentation/scenario.ts b/e2e/scenarios/mcp-instrumentation/scenario.ts new file mode 100644 index 000000000..d41baffa6 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/scenario.ts @@ -0,0 +1,10 @@ +import { runMain } from "../../helpers/scenario-runtime"; +import { + loadMCPModules, + runWrappedMCPInstrumentation, +} from "./scenario.impl.mjs"; + +runMain(async () => { + const mcp = await loadMCPModules((specifier: string) => import(specifier)); + await runWrappedMCPInstrumentation(mcp); +}); diff --git a/js/src/auto-instrumentations/configs/all.ts b/js/src/auto-instrumentations/configs/all.ts index cb0daea47..751e0b8ac 100644 --- a/js/src/auto-instrumentations/configs/all.ts +++ b/js/src/auto-instrumentations/configs/all.ts @@ -37,6 +37,7 @@ import { piCodingAgentConfigs } from "./pi-coding-agent"; import { strandsAgentSDKConfigs } from "./strands-agent-sdk"; import { elevenLabsConfigs } from "./elevenlabs"; import { voyageAIConfigs } from "./voyageai"; +import { mcpConfigs } from "./mcp"; import { typeSafeConfigs } from "./typesafe"; interface InstrumentationConfigGroup { @@ -141,6 +142,10 @@ const defaultInstrumentationConfigGroups: readonly InstrumentationConfigGroup[] integrations: ["voyageai"], configs: voyageAIConfigs, }, + { + integrations: ["mcp"], + configs: mcpConfigs, + }, { integrations: ["typesafe"], configs: typeSafeConfigs, diff --git a/js/src/auto-instrumentations/configs/mcp.ts b/js/src/auto-instrumentations/configs/mcp.ts new file mode 100644 index 000000000..c1aee411c --- /dev/null +++ b/js/src/auto-instrumentations/configs/mcp.ts @@ -0,0 +1,31 @@ +import type { InstrumentationConfig } from "../orchestrion-js"; +import { + mcpSDKChannels, + mcpServerChannels, +} from "../../instrumentation/plugins/mcp-interceptors"; + +export const mcpConfigs: InstrumentationConfig[] = ( + ["connect", "setRequestHandler"] as const +).flatMap((methodName) => [ + ...["dist/esm/shared/protocol.js", "dist/cjs/shared/protocol.js"].map( + (filePath) => ({ + channelName: mcpSDKChannels[methodName].channelName, + module: { + name: "@modelcontextprotocol/sdk", + versionRange: ">=1.2.0 <2.0.0", + filePath, + }, + functionQuery: { className: "Protocol", methodName }, + }), + ), + // v2 bundles `Protocol` into a content-hashed chunk renamed on every release. + ...["dist/src-*.mjs", "dist/src-*.cjs"].map((filePath) => ({ + channelName: mcpServerChannels[methodName].channelName, + module: { + name: "@modelcontextprotocol/server", + versionRange: ">=2.0.0 <3.0.0", + filePath, + }, + functionQuery: { className: "Protocol", methodName }, + })), +]); diff --git a/js/src/auto-instrumentations/orchestrion-js/matcher.test.ts b/js/src/auto-instrumentations/orchestrion-js/matcher.test.ts new file mode 100644 index 000000000..997b36144 --- /dev/null +++ b/js/src/auto-instrumentations/orchestrion-js/matcher.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { InstrumentationMatcher } from "./matcher"; + +describe("InstrumentationMatcher", () => { + const matcher = new InstrumentationMatcher([ + { + channelName: "request", + module: { + name: "example", + versionRange: ">=1.0.0", + filePath: "dist/src-*.mjs", + }, + functionQuery: { className: "Client", methodName: "request" }, + }, + { + channelName: "exact", + module: { + name: "example", + versionRange: ">=1.0.0", + filePath: "dist/index.mjs", + }, + functionQuery: { functionName: "exact" }, + }, + ]); + + it("matches content-hashed chunks with a file path pattern", () => { + expect( + matcher.getTransformer("example", "1.2.0", "dist/src-DdvbAHrR.mjs"), + ).toBeDefined(); + expect( + matcher.getTransformer("example", "1.2.0", "dist\\src-BvE2_t-z.mjs"), + ).toBeDefined(); + }); + + it("keeps patterns within one path segment and file extension", () => { + expect( + matcher.getTransformer("example", "1.2.0", "dist/src-abc/index.mjs"), + ).toBeUndefined(); + expect( + matcher.getTransformer("example", "1.2.0", "dist/src-DdvbAHrR.cjs"), + ).toBeUndefined(); + expect( + matcher.getTransformer("example", "1.2.0", "dist/src-DdvbAHrRxmjs"), + ).toBeUndefined(); + expect( + matcher.getTransformer("example", "0.9.0", "dist/src-DdvbAHrR.mjs"), + ).toBeUndefined(); + }); + + it("matches file paths without a pattern exactly", () => { + expect( + matcher.getTransformer("example", "1.2.0", "dist/index.mjs"), + ).toBeDefined(); + expect( + matcher.getTransformer("example", "1.2.0", "dist/index.mjs.map"), + ).toBeUndefined(); + }); +}); diff --git a/js/src/auto-instrumentations/orchestrion-js/matcher.ts b/js/src/auto-instrumentations/orchestrion-js/matcher.ts index 6ba7337d5..bfe145b3a 100644 --- a/js/src/auto-instrumentations/orchestrion-js/matcher.ts +++ b/js/src/auto-instrumentations/orchestrion-js/matcher.ts @@ -7,17 +7,35 @@ import semifies from "semifies"; import { Transformer } from "./transformer"; import type { InstrumentationConfig } from "./types"; +function compileFilePath(filePath: string): (path: string) => boolean { + if (!filePath.includes("*")) { + return (path) => path === filePath; + } + const pattern = new RegExp( + `^${filePath + .split("*") + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) + .join("[^/]*")}$`, + ); + return (path) => pattern.test(path); +} + /** * Matches instrumentation configs against a given module/file/version triple and * returns a cached Transformer for matching configs. */ export class InstrumentationMatcher { private configs = new Map(); + private filePathMatchers = new Map boolean>(); private transformers: Record = {}; constructor(configs: InstrumentationConfig[]) { for (const config of configs) { const name = config.module.name; + const { filePath } = config.module; + if (!this.filePathMatchers.has(filePath)) { + this.filePathMatchers.set(filePath, compileFilePath(filePath)); + } const packageConfigs = this.configs.get(name); if (packageConfigs) packageConfigs.push(config); else this.configs.set(name, [config]); @@ -46,7 +64,8 @@ export class InstrumentationMatcher { const configs = packageConfigs.filter( ({ module: mod }) => - mod.filePath === filePath && semifies(version, mod.versionRange), + this.filePathMatchers.get(mod.filePath)!(filePath) && + semifies(version, mod.versionRange), ); if (configs.length === 0) { diff --git a/js/src/auto-instrumentations/orchestrion-js/types.ts b/js/src/auto-instrumentations/orchestrion-js/types.ts index 083773b2e..080499137 100644 --- a/js/src/auto-instrumentations/orchestrion-js/types.ts +++ b/js/src/auto-instrumentations/orchestrion-js/types.ts @@ -72,7 +72,8 @@ export interface ModuleMatcher { versionRange: string; /** - * The path of the file you want to match from the module root. + * The path of the file you want to match from the module root. `*` matches + * any characters within one path segment, for content-hashed chunks. */ filePath: string; } diff --git a/js/src/exports.ts b/js/src/exports.ts index ff9f7fed8..9cf8b897b 100644 --- a/js/src/exports.ts +++ b/js/src/exports.ts @@ -243,6 +243,7 @@ export { wrapLangGraphSDK } from "./wrappers/langgraph-sdk"; export { wrapOllama } from "./wrappers/ollama"; export { wrapCohere } from "./wrappers/cohere"; export { wrapVoyageAI } from "./wrappers/voyageai"; +export { wrapMCPServer } from "./wrappers/mcp"; export { wrapWorkersAI } from "./wrappers/workers-ai"; export { wrapTypeSafe } from "./wrappers/typesafe"; export { wrapGroq } from "./wrappers/groq"; diff --git a/js/src/git-command.test.ts b/js/src/git-command.test.ts index 0e483ed8a..4298b1b00 100644 --- a/js/src/git-command.test.ts +++ b/js/src/git-command.test.ts @@ -1,5 +1,8 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import * as path from "node:path"; -import { afterEach, expect, test } from "vitest"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { findGitExecutable, resetGitExecutableCacheForTests, @@ -7,16 +10,57 @@ import { runGitCommand, } from "./git-command"; -afterEach(() => { +vi.mock("node:child_process", () => ({ + execFile: vi.fn( + ( + _file: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string) => void, + ) => callback(null, "git version 2.0.0\n"), + ), +})); + +let gitDir: string; +let gitExecutable: string; + +beforeEach(async () => { + gitDir = await mkdtemp(path.join(tmpdir(), "braintrust-git-command-")); + gitExecutable = path.join( + gitDir, + process.platform === "win32" ? "git.exe" : "git", + ); + await writeFile(gitExecutable, "", { mode: 0o755 }); + const pathKey = Object.keys(process.env).find( + (name) => name.toUpperCase() === "PATH", + ); + vi.stubEnv(pathKey ?? "PATH", gitDir); +}); + +afterEach(async () => { resetGitExecutableCacheForTests(); + vi.unstubAllEnvs(); + vi.clearAllMocks(); + await rm(gitDir, { recursive: true, force: true }); }); test("resolves git to an absolute executable path", async () => { const executable = await resolveGitExecutable(); - expect(executable).toBeDefined(); + expect(executable).toBe(gitExecutable); expect(path.isAbsolute(executable!)).toBe(true); - await expect(runGitCommand(["--version"])).resolves.toMatch(/^git version /); +}); + +test("runs git using the resolved executable without a shell", async () => { + await expect(runGitCommand(["--version"], { cwd: gitDir })).resolves.toBe( + "git version 2.0.0\n", + ); + expect(execFile).toHaveBeenCalledWith( + gitExecutable, + ["--version"], + { cwd: gitDir, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, + expect.any(Function), + ); }); test("ignores relative PATH entries", async () => { diff --git a/js/src/instrumentation/braintrust-plugin.test.ts b/js/src/instrumentation/braintrust-plugin.test.ts index f6510cbe2..c979690ec 100644 --- a/js/src/instrumentation/braintrust-plugin.test.ts +++ b/js/src/instrumentation/braintrust-plugin.test.ts @@ -23,6 +23,7 @@ import { LangSmithPlugin } from "./plugins/langsmith-plugin"; import { PiCodingAgentPlugin } from "./plugins/pi-coding-agent-plugin"; import { StrandsAgentSDKPlugin } from "./plugins/strands-agent-sdk-plugin"; import { VoyageAIPlugin } from "./plugins/voyageai-plugin"; +import { MCPPlugin } from "./plugins/mcp-plugin"; import { TypeSafePlugin } from "./plugins/typesafe-plugin"; import { CloudflareAIChatPlugin } from "./plugins/cloudflare-ai-chat-plugin"; import { CloudflareAgentsPlugin } from "./plugins/cloudflare-agents-plugin"; @@ -139,6 +140,10 @@ vi.mock("./plugins/voyageai-plugin", () => ({ VoyageAIPlugin: createPluginClassMock(), })); +vi.mock("./plugins/mcp-plugin", () => ({ + MCPPlugin: createPluginClassMock(), +})); + vi.mock("./plugins/typesafe-plugin", () => ({ TypeSafePlugin: createPluginClassMock(), })); @@ -291,6 +296,15 @@ describe("BraintrustPlugin", () => { expect(mockInstance.enable).toHaveBeenCalledTimes(1); }); + it("should create and enable MCP plugin by default", () => { + const plugin = new BraintrustPlugin(); + plugin.enable(); + + expect(MCPPlugin).toHaveBeenCalledTimes(1); + const mockInstance = vi.mocked(MCPPlugin).mock.results[0].value; + expect(mockInstance.enable).toHaveBeenCalledTimes(1); + }); + it("should create and enable TypeSafe plugin by default", () => { const plugin = new BraintrustPlugin(); plugin.enable(); @@ -1213,6 +1227,15 @@ describe("BraintrustPlugin", () => { expect(VoyageAIPlugin).not.toHaveBeenCalled(); }); + it("should not create MCP plugin when mcp: false", () => { + const plugin = new BraintrustPlugin({ + integrations: { mcp: false }, + }); + plugin.enable(); + + expect(MCPPlugin).not.toHaveBeenCalled(); + }); + it("should not create TypeSafe plugin when typesafe: false", () => { const plugin = new BraintrustPlugin({ integrations: { typesafe: false }, diff --git a/js/src/instrumentation/braintrust-plugin.ts b/js/src/instrumentation/braintrust-plugin.ts index 188712d80..52220015c 100644 --- a/js/src/instrumentation/braintrust-plugin.ts +++ b/js/src/instrumentation/braintrust-plugin.ts @@ -29,6 +29,7 @@ import { PiCodingAgentPlugin } from "./plugins/pi-coding-agent-plugin"; import { StrandsAgentSDKPlugin } from "./plugins/strands-agent-sdk-plugin"; import { ElevenLabsPlugin } from "./plugins/elevenlabs-plugin"; import { LiveKitPlugin } from "./plugins/livekit-plugin"; +import { MCPPlugin } from "./plugins/mcp-plugin"; import { VoyageAIPlugin } from "./plugins/voyageai-plugin"; import { WorkersAIPlugin } from "./plugins/workers-ai-plugin"; import { TypeSafePlugin } from "./plugins/typesafe-plugin"; @@ -56,6 +57,7 @@ export interface BraintrustPluginConfig { * - Ollama SDK * - Cohere SDK * - Voyage AI SDK + * - Model Context Protocol servers * * The plugin is automatically enabled when the Braintrust library is loaded. * Individual integrations can be disabled via configuration. @@ -94,6 +96,7 @@ export class BraintrustPlugin extends BasePlugin { private elevenLabsPlugin: ElevenLabsPlugin | null = null; private liveKitPlugin: LiveKitPlugin | null = null; private voyageAIPlugin: VoyageAIPlugin | null = null; + private mcpPlugin: MCPPlugin | null = null; private workersAIPlugin: WorkersAIPlugin | null = null; private typeSafePlugin: TypeSafePlugin | null = null; private cloudflareAIChatPlugin: CloudflareAIChatPlugin | null = null; @@ -225,6 +228,11 @@ export class BraintrustPlugin extends BasePlugin { this.voyageAIPlugin.enable(); } + if (integrations.mcp !== false) { + this.mcpPlugin = new MCPPlugin(); + this.mcpPlugin.enable(); + } + if (integrations.workersAI !== false) { this.workersAIPlugin = new WorkersAIPlugin(); this.workersAIPlugin.enable(); @@ -413,6 +421,11 @@ export class BraintrustPlugin extends BasePlugin { this.voyageAIPlugin = null; } + if (this.mcpPlugin) { + this.mcpPlugin.disable(); + this.mcpPlugin = null; + } + if (this.workersAIPlugin) { this.workersAIPlugin.disable(); this.workersAIPlugin = null; diff --git a/js/src/instrumentation/config.ts b/js/src/instrumentation/config.ts index 1cf7f09a8..c16436ed2 100644 --- a/js/src/instrumentation/config.ts +++ b/js/src/instrumentation/config.ts @@ -64,6 +64,7 @@ export interface InstrumentationIntegrationsConfig { workersAI?: boolean; elevenlabs?: boolean; livekit?: boolean; + mcp?: boolean; } export const DEFAULT_STREAMING_ATTACHMENT_BUFFER_BYTES = 4 * 1024 * 1024; @@ -187,6 +188,10 @@ const envIntegrationAliases: Record< "@elevenlabs/elevenlabs-js": "elevenlabs", livekit: "livekit", "@livekit/agents": "livekit", + mcp: "mcp", + modelcontextprotocol: "mcp", + "@modelcontextprotocol/sdk": "mcp", + "@modelcontextprotocol/server": "mcp", }; export function getDefaultInstrumentationIntegrations(): Record< @@ -232,6 +237,7 @@ export function getDefaultInstrumentationIntegrations(): Record< workersAI: true, elevenlabs: true, livekit: true, + mcp: true, piCodingAgent: true, strandsAgentSDK: true, cloudflareAgents: true, diff --git a/js/src/instrumentation/plugins/instrumentation-names.test.ts b/js/src/instrumentation/plugins/instrumentation-names.test.ts index 839ced862..2cf5bca49 100644 --- a/js/src/instrumentation/plugins/instrumentation-names.test.ts +++ b/js/src/instrumentation/plugins/instrumentation-names.test.ts @@ -25,6 +25,11 @@ import { huggingFaceChannels } from "./huggingface-interceptors"; import { langChainChannels } from "./langchain-interceptors"; import { langGraphSDKChannels } from "./langgraph-sdk-interceptors"; import { langSmithChannels } from "./langsmith-interceptors"; +import { + mcpChannels, + mcpSDKChannels, + mcpServerChannels, +} from "./mcp-interceptors"; import { mistralChannels } from "./mistral-interceptors"; import { ollamaChannels } from "./ollama-interceptors"; import { openAIAgentsCoreChannels } from "./openai-agents-interceptors"; @@ -73,6 +78,9 @@ describe("wrapping definitions are independent of span provenance", () => { [langGraphSDKChannels.wait, INSTRUMENTATION_NAMES.LANGGRAPH_SDK], [langChainChannels.configure, INSTRUMENTATION_NAMES.LANGCHAIN], [langSmithChannels.createRun, INSTRUMENTATION_NAMES.LANGSMITH], + [mcpChannels.requestHandler, INSTRUMENTATION_NAMES.MCP], + [mcpSDKChannels.setRequestHandler, INSTRUMENTATION_NAMES.MCP], + [mcpServerChannels.setRequestHandler, INSTRUMENTATION_NAMES.MCP], [mistralChannels.chatComplete, INSTRUMENTATION_NAMES.MISTRAL], [ollamaChannels.chat, INSTRUMENTATION_NAMES.OLLAMA], [ diff --git a/js/src/instrumentation/plugins/mcp-interceptors.ts b/js/src/instrumentation/plugins/mcp-interceptors.ts new file mode 100644 index 000000000..c841bfc3f --- /dev/null +++ b/js/src/instrumentation/plugins/mcp-interceptors.ts @@ -0,0 +1,187 @@ +import { channel, defineInterceptor } from "../core/interceptor-definitions"; +import type { + MCPProtocol, + MCPRequest, + MCPRequestHandler, + MCPRequestHandlerExtra, + MCPTransport, +} from "../../vendor-sdk-types/mcp"; + +const protocolChannels = { + connect: channel<[MCPTransport, ...unknown[]], unknown>({ + channelName: "Protocol.connect", + }), + setRequestHandler: channel({ + channelName: "Protocol.setRequestHandler", + }), +}; +export const mcpSDKChannels = defineInterceptor( + "@modelcontextprotocol/sdk", + protocolChannels, +); +export const mcpServerChannels = defineInterceptor( + "@modelcontextprotocol/server", + protocolChannels, +); +export const mcpChannels = defineInterceptor("mcp", { + connect: channel<[MCPTransport, ...unknown[]], unknown>({ + channelName: "connect", + }), + message: channel< + [unknown, ...unknown[]], + unknown, + { transport: MCPTransport } + >({ channelName: "message" }), + send: channel<[unknown, ...unknown[]], unknown, { transport: MCPTransport }>({ + channelName: "send", + }), + close: channel({ + channelName: "close", + }), + protocolError: channel<[unknown], unknown>({ channelName: "protocolError" }), + requestHandler: channel< + [MCPRequest, MCPRequestHandlerExtra], + unknown, + { protocol: MCPProtocol } + >({ channelName: "requestHandler" }), +}); + +const wrapped = Symbol.for("braintrust.mcp.transport-wrapper"); +function wrapMethod( + target: object, + key: string, + wrap: ( + original: (...args: unknown[]) => unknown, + ) => (...args: unknown[]) => unknown, +): void { + const original = Reflect.get(target, key); + if (typeof original !== "function" || original[wrapped]) return; + const replacement = wrap(original); + Object.defineProperty(replacement, wrapped, { value: true }); + Reflect.set(target, key, replacement); +} + +/** Install only generic invocation hooks; tracing is registered by the plugin. */ +export function wrapMCPTransport( + transport: MCPTransport, + protocol: MCPProtocol, +): void { + const wrapCallbacks = () => { + wrapMethod( + transport, + "onmessage", + (original) => + function (this: unknown, ...args) { + // Fallback handlers can be assigned directly, even after connect. + if (protocol.fallbackRequestHandler) { + protocol.fallbackRequestHandler = wrapMCPRequestHandler( + protocol.fallbackRequestHandler, + protocol, + ); + } + return mcpChannels.message.invoke( + original, + this, + args as [unknown, ...unknown[]], + { transport }, + ); + }, + ); + wrapMethod( + transport, + "onclose", + (original) => + function (this: unknown, ...args) { + return mcpChannels.close.invoke(original, this, args, { transport }); + }, + ); + }; + // Protocol.connect installs callbacks before calling start, which may deliver + // queued messages immediately. Also support wrapping an already connected server. + wrapMethod( + transport, + "start", + (original) => + function (this: unknown, ...args) { + wrapCallbacks(); + return Reflect.apply(original, this, args); + }, + ); + wrapMethod( + transport, + "send", + (original) => + function (this: unknown, ...args) { + return mcpChannels.send.invoke( + original, + this, + args as [unknown, ...unknown[]], + { transport }, + ); + }, + ); + wrapCallbacks(); +} + +export function wrapMCPRequestHandler( + handler: MCPRequestHandler, + protocol: MCPProtocol, +): MCPRequestHandler { + if (Reflect.get(handler, wrapped) === protocol) return handler; + const replacement: MCPRequestHandler = function ( + this: unknown, + request, + extra, + ) { + return mcpChannels.requestHandler.invoke(handler, this, [request, extra], { + protocol, + }); + }; + Object.defineProperty(replacement, wrapped, { value: protocol }); + return replacement; +} + +export function wrapMCPProtocol(protocol: MCPProtocol): void { + wrapMethod( + protocol, + "connect", + (original) => + function (this: unknown, ...args) { + return mcpChannels.connect.invoke( + original, + this, + args as [MCPTransport, ...unknown[]], + {}, + ); + }, + ); + wrapMethod( + protocol, + "setRequestHandler", + (original) => + function (this: unknown, ...args) { + const index = args.length - 1; + if (typeof args[index] === "function") + args[index] = wrapMCPRequestHandler( + args[index] as MCPRequestHandler, + protocol, + ); + return Reflect.apply(original, this, args); + }, + ); + wrapMethod( + protocol, + "_onerror", + (original) => + function (this: unknown, error) { + return mcpChannels.protocolError.invoke(original, this, [error], {}); + }, + ); + for (const [method, handler] of protocol._requestHandlers ?? []) { + protocol._requestHandlers!.set( + method, + wrapMCPRequestHandler(handler, protocol), + ); + } + if (protocol.transport) wrapMCPTransport(protocol.transport, protocol); +} diff --git a/js/src/instrumentation/plugins/mcp-plugin.test.ts b/js/src/instrumentation/plugins/mcp-plugin.test.ts new file mode 100644 index 000000000..e06f96a06 --- /dev/null +++ b/js/src/instrumentation/plugins/mcp-plugin.test.ts @@ -0,0 +1,858 @@ +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Attachment, + _exportsForTestingOnly, + initLogger, + traced, +} from "../../logger"; +import { configureNode } from "../../node/config"; +import type { + MCPProtocol, + MCPRequest, + MCPRequestHandler, + MCPRequestHandlerExtra, +} from "../../vendor-sdk-types/mcp"; +import { + mcpChannels, + mcpSDKChannels, + mcpServerChannels, +} from "./mcp-interceptors"; + +try { + configureNode(); +} catch { + // Best-effort initialization for test environments. +} + +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +function createProtocol(transport?: object): MCPProtocol { + return { + connect: () => {}, + setRequestHandler: () => {}, + transport: Object.assign(transport ?? {}, { start() {}, send() {} }), + }; +} + +function handleRequest( + handler: MCPRequestHandler, + request: MCPRequest, + extra: MCPRequestHandlerExtra = {}, + protocol: MCPProtocol = createProtocol(), +): unknown { + const transport = protocol.transport!; + transport.sessionId = extra.sessionId; + const id = extra.mcpReq?.id ?? extra.requestId ?? request.id ?? 0; + return mcpChannels.message.invoke( + async () => { + const result = await mcpChannels.requestHandler.invoke( + handler, + undefined, + [request, { ...extra, requestId: id }], + { protocol }, + ); + if (!extra.signal?.aborted && !extra.mcpReq?.signal?.aborted) { + await mcpChannels.send.invoke( + transport.send, + transport, + [{ jsonrpc: "2.0", id, result }], + { transport }, + ); + } + return result; + }, + transport, + [{ ...request, id }], + { transport }, + ); +} + +describe("MCPPlugin", () => { + let backgroundLogger: ReturnType< + typeof _exportsForTestingOnly.useTestBackgroundLogger + >; + + beforeAll(async () => { + await _exportsForTestingOnly.simulateLoginForTests(); + }); + + beforeEach(() => { + backgroundLogger = _exportsForTestingOnly.useTestBackgroundLogger(); + initLogger({ + projectName: "mcp-plugin.test.ts", + projectId: "test-project-id", + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + _exportsForTestingOnly.clearTestBackgroundLogger(); + }); + + async function drainSpans(): Promise[]> { + return (await backgroundLogger.drain()) as Record[]; + } + + it("traces tool calls with the transport session ID", async () => { + const result = await handleRequest( + async () => ({ + content: [{ type: "text", text: "sunny" }], + structuredContent: { forecast: "sunny" }, + _meta: { "example/trace": "omitted" }, + }), + { + method: "tools/call", + params: { name: "get_weather", arguments: { city: "Vienna" } }, + }, + { sessionId: "session-1", requestId: 7 }, + ); + + expect(result).toEqual({ + content: [{ type: "text", text: "sunny" }], + structuredContent: { forecast: "sunny" }, + _meta: { "example/trace": "omitted" }, + }); + + const [span] = await drainSpans(); + expect(span.span_attributes).toMatchObject({ + name: "get_weather", + type: "tool", + }); + expect(span.input).toEqual({ city: "Vienna" }); + expect(span.output).toEqual({ + content: [{ type: "text", text: "sunny" }], + structuredContent: { forecast: "sunny" }, + }); + expect(span.metadata).toEqual({ + "mcp.method": "tools/call", + "mcp.request_id": 7, + "mcp.session_id": "session-1", + }); + expect(span.error).toBeUndefined(); + }); + + it("synthesizes a stable session ID per transport", async () => { + const firstConnection = createProtocol({}); + const secondConnection = createProtocol({}); + const request = { method: "tools/call", params: { name: "noop" } }; + const handler = async () => ({ content: [] }); + + await handleRequest( + handler, + request, + { mcpReq: { id: 1 } }, + firstConnection, + ); + await handleRequest( + handler, + request, + { mcpReq: { id: 2 } }, + firstConnection, + ); + await handleRequest( + handler, + request, + { mcpReq: { id: 1 } }, + secondConnection, + ); + + const spans = await drainSpans(); + const sessionIds = spans.map((span) => span.metadata["mcp.session_id"]); + expect(spans.map((span) => span.metadata["mcp.request_id"])).toEqual([ + 1, 2, 1, + ]); + expect(sessionIds[0]).toEqual(expect.any(String)); + expect(sessionIds[1]).toBe(sessionIds[0]); + expect(sessionIds[2]).not.toBe(sessionIds[0]); + }); + + it("logs tool error results and thrown errors", async () => { + await handleRequest( + async () => ({ + content: [{ type: "text", text: "City not found" }], + isError: true, + }), + { method: "tools/call", params: { name: "get_weather" } }, + ); + + const failure = new Error("Tool missing not found"); + await expect( + handleRequest( + async () => { + throw failure; + }, + { method: "tools/call", params: { name: "missing" } }, + ), + ).rejects.toBe(failure); + + const [errorResult, thrown] = await drainSpans(); + expect(errorResult.error).toBe("City not found"); + expect(errorResult.output).toEqual({ + content: [{ type: "text", text: "City not found" }], + isError: true, + }); + expect(thrown.span_attributes.name).toBe("missing"); + expect(thrown.error).toContain("Tool missing not found"); + }); + + it("traces prompts and resources without dynamic span names", async () => { + await handleRequest( + async () => ({ + messages: [ + { role: "user", content: { type: "text", text: "Review this" } }, + ], + }), + { + method: "prompts/get", + params: { name: "review", arguments: { language: "ts" } }, + }, + ); + await handleRequest( + async () => ({ + contents: [{ uri: "file:///notes.txt", text: "notes" }], + }), + { method: "resources/read", params: { uri: "file:///notes.txt" } }, + ); + + const [prompt, resource] = await drainSpans(); + expect(prompt.span_attributes).toMatchObject({ + name: "prompts/get", + type: "function", + }); + expect(prompt.input).toEqual({ language: "ts" }); + expect(prompt.metadata).toMatchObject({ + "mcp.method": "prompts/get", + "mcp.prompt_name": "review", + }); + expect(resource.span_attributes).toMatchObject({ + name: "resources/read", + type: "function", + }); + expect(resource.input).toEqual({ uri: "file:///notes.txt" }); + expect(resource.metadata).toMatchObject({ + "mcp.method": "resources/read", + "mcp.resource_uri": "file:///notes.txt", + }); + expect(resource.output).toEqual({ + contents: [{ uri: "file:///notes.txt", text: "notes" }], + }); + }); + + it.each(["tools/list", "initialize", "constructor", "__proto__"])( + "traces %s without capturing arbitrary payloads", + async (method) => { + const result = await handleRequest(async () => ({ ok: true }), { + method, + }); + + expect(result).toEqual({ ok: true }); + const [span] = await drainSpans(); + expect(span.span_attributes).toMatchObject({ + name: method, + type: "function", + }); + expect(span.input).toBeUndefined(); + expect(span.output).toBeUndefined(); + }, + ); + + it("nests spans created by handlers under the request span", async () => { + await handleRequest( + async () => + traced(async () => ({ content: [] }), { name: "tool-internals" }), + { method: "tools/call", params: { name: "lookup" } }, + ); + + const spans = await drainSpans(); + const tool = spans.find((span) => span.span_attributes.name === "lookup"); + const child = spans.find( + (span) => span.span_attributes.name === "tool-internals", + ); + expect(child?.span_parents).toEqual([tool?.span_id]); + }); + + it("omits inline media unless attachment capture is enabled", async () => { + const result = { + content: [ + { type: "image", data: PNG_BASE64, mimeType: "image/png" }, + { type: "audio", data: PNG_BASE64, mimeType: "audio/wav" }, + { + type: "resource", + resource: { + uri: "file:///pixel.png", + blob: PNG_BASE64, + mimeType: "image/png", + }, + }, + ], + }; + const request = { method: "tools/call", params: { name: "render" } }; + + await handleRequest(async () => result, request); + vi.stubEnv("BRAINTRUST_CAPTURE_ATTACHMENTS", "1"); + await handleRequest(async () => result, request); + + const [disabled, enabled] = await drainSpans(); + expect(disabled.output.content).toEqual([ + { type: "image", data: "", mimeType: "image/png" }, + { type: "audio", data: "", mimeType: "audio/wav" }, + { + type: "resource", + resource: { + uri: "file:///pixel.png", + blob: "", + mimeType: "image/png", + }, + }, + ]); + expect(enabled.output.content[0].data).toBeInstanceOf(Attachment); + expect(enabled.output.content[1].data).toBe(""); + expect(enabled.output.content[2].resource.blob).toBeInstanceOf(Attachment); + expect(result.content[0].data).toBe(PNG_BASE64); + }); + + it.each([ + ["v1", mcpSDKChannels.setRequestHandler], + ["v2", mcpServerChannels.setRequestHandler], + ])( + "wraps handlers registered through %s setRequestHandler", + async (_, hook) => { + const handlers = new Map(); + const protocol: MCPProtocol = { + ...createProtocol(), + setRequestHandler(method: unknown, handler: unknown) { + handlers.set(method as string, handler as MCPRequestHandler); + }, + }; + + hook.invoke( + protocol.setRequestHandler, + protocol, + ["tools/call", async () => ({ content: [] })], + {}, + ); + // v2 re-invokes the handler with the same context for input-required rounds. + const extra = { sessionId: "session-2" }; + const request = { method: "tools/call", params: { name: "registered" } }; + await handleRequest( + handlers.get("tools/call")!, + request, + extra, + protocol, + ); + await handleRequest( + handlers.get("tools/call")!, + request, + extra, + protocol, + ); + + const spans = await drainSpans(); + expect(spans.map((span) => span.span_attributes.name)).toEqual([ + "registered", + "registered", + ]); + expect(spans[0].metadata["mcp.session_id"]).toBe("session-2"); + }, + ); + + it("wraps custom v2 handlers without changing their schemas", () => { + const schemas = { params: {} }; + const handler = async () => ({}); + const setRequestHandler = vi.fn(); + + mcpServerChannels.setRequestHandler.invoke( + setRequestHandler, + createProtocol(), + ["acme/search", schemas, handler], + {}, + ); + + expect(setRequestHandler).toHaveBeenCalledWith( + "acme/search", + schemas, + expect.any(Function), + ); + }); + it("keeps a request open through asynchronous response sending", async () => { + let completeSend!: () => void; + const sent = new Promise((resolve) => { + completeSend = resolve; + }); + const protocol = createProtocol(); + protocol.transport!.send = () => sent; + const result = handleRequest( + async () => ({ content: [] }), + { method: "tools/call", params: { name: "slow-send" } }, + {}, + protocol, + ); + await Promise.resolve(); + const [started] = await drainSpans(); + expect(started.metrics.end).toBeUndefined(); + completeSend(); + await result; + const [ended] = await drainSpans(); + expect(ended.metrics.end).toEqual(expect.any(Number)); + }); + + it("captures validation errors without a handler and omits error data", async () => { + const transport = createProtocol().transport!; + await mcpChannels.message.invoke( + () => + mcpChannels.send.invoke( + transport.send, + transport, + [ + { + id: 0, + error: { + code: -32602, + message: "Invalid arguments", + data: { secret: "hidden" }, + }, + }, + ], + { transport }, + ), + transport, + [{ id: 0, method: "tools/call", params: { name: "invalid" } }], + { transport }, + ); + const [span] = await drainSpans(); + expect(span.error).toContain("-32602"); + expect(span.error).toContain("Invalid arguments"); + expect(span.error).not.toContain("hidden"); + expect(span.input).toBeUndefined(); + expect(span.output).toBeUndefined(); + expect(span.metadata["mcp.request_id"]).toBe(0); + }); + + it("propagates response-send failures and ends the span", async () => { + const protocol = createProtocol(); + const failure = new Error("send failed"); + protocol.transport!.send = () => Promise.reject(failure); + await expect( + handleRequest( + async () => ({ content: [] }), + { method: "tools/call" }, + {}, + protocol, + ), + ).rejects.toBe(failure); + const [span] = await drainSpans(); + expect(span.error).toContain("send failed"); + expect(span.metrics.end).toEqual(expect.any(Number)); + }); + + it.each(["v1", "v2"])( + "ends %s cancellation after processing without an error", + async (version) => { + const controller = new AbortController(); + let complete!: () => void; + const waiting = new Promise((resolve) => { + complete = resolve; + }); + const protocol = createProtocol(); + const extra = + version === "v1" + ? { signal: controller.signal } + : { mcpReq: { id: 0, signal: controller.signal } }; + const result = handleRequest( + async () => { + await waiting; + throw new Error("arbitrary cancellation exception"); + }, + { method: "tools/call" }, + extra, + protocol, + ); + controller.abort(); + const [started] = await drainSpans(); + expect(started.metrics.end).toBeUndefined(); + complete(); + await expect(result).rejects.toThrow("arbitrary cancellation exception"); + const [ended] = await drainSpans(); + expect(ended.metadata["mcp.cancelled"]).toBe(true); + expect(ended.error).toBeUndefined(); + expect(ended.metrics.end).toEqual(expect.any(Number)); + mcpChannels.send.invoke( + protocol.transport!.send, + protocol.transport, + [{ id: 0, result: { content: [] } }], + { transport: protocol.transport! }, + ); + expect(await drainSpans()).toEqual([]); + }, + ); + + it("does not mistake an error message for cancellation", async () => { + await expect( + handleRequest( + async () => { + throw new Error("request cancelled by upstream"); + }, + { method: "tools/call" }, + ), + ).rejects.toThrow(); + const [span] = await drainSpans(); + expect(span.error).toContain("cancelled by upstream"); + expect(span.metadata["mcp.cancelled"]).toBeUndefined(); + }); + + it("ends pending requests once on connection closure, ignoring late responses", async () => { + const transport = createProtocol().transport!; + mcpChannels.message.invoke( + () => {}, + transport, + [{ id: 1, method: "ping" }], + { transport }, + ); + mcpChannels.close.invoke(() => {}, transport, [], { transport }); + mcpChannels.close.invoke(() => {}, transport, [], { transport }); + mcpChannels.send.invoke( + transport.send, + transport, + [{ id: 1, result: {} }], + { transport }, + ); + const [span] = await drainSpans(); + expect(span.error).toContain("connection closed"); + expect(span.output).toBeUndefined(); + expect(span.metrics.end).toEqual(expect.any(Number)); + }); + + it("keeps numeric and string IDs separate and permits ID reuse after completion", async () => { + const protocol = createProtocol(); + const transports = [protocol.transport!, createProtocol().transport!]; + for (const transport of transports) { + for (const id of [0, "0"]) { + mcpChannels.message.invoke( + () => {}, + transport, + [{ id, method: "ping" }], + { transport }, + ); + } + for (const id of [0, "0"]) { + mcpChannels.send.invoke( + transport.send, + transport, + [{ id, result: {} }], + { transport }, + ); + } + } + await handleRequest( + async () => ({}), + { method: "ping", id: 0 }, + {}, + protocol, + ); + const spans = await drainSpans(); + expect(spans).toHaveLength(5); + expect(new Set(spans.map((span) => span.span_id)).size).toBe(5); + expect(spans.map((span) => span.metadata["mcp.request_id"])).toEqual([ + 0, + "0", + 0, + "0", + 0, + ]); + expect(spans.every((span) => typeof span.metrics.end === "number")).toBe( + true, + ); + }); + + it("translates allowlisted metadata and preserves application _meta", async () => { + const result = { + _meta: { + "io.modelcontextprotocol/serverInfo": { + name: "weather", + title: "Weather", + version: "1", + secret: "hidden", + }, + traceparent: "ignored", + }, + structuredContent: { _meta: { app: "preserved" } }, + content: [ + { type: "text", text: "sunny", _meta: { token: "hidden" } }, + { + type: "resource", + resource: { + uri: "notes://forecast", + text: "notes", + _meta: { private: true }, + }, + }, + ], + }; + const args = { _meta: { application: true } }; + expect( + await handleRequest(async () => result, { + method: "tools/call", + params: { + arguments: args, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2025-11-25", + "io.modelcontextprotocol/clientInfo": { + name: "client", + title: 12, + version: "2", + secret: "hidden", + }, + "io.modelcontextprotocol/capabilities": { secret: true }, + baggage: "private=value", + }, + }, + }), + ).toBe(result); + const [span] = await drainSpans(); + expect(span.input).toEqual(args); + expect(span.metadata).toMatchObject({ + "mcp.protocol_version": "2025-11-25", + "mcp.client.name": "client", + "mcp.client.version": "2", + "mcp.server.name": "weather", + "mcp.server.title": "Weather", + "mcp.server.version": "1", + }); + expect(span.metadata["mcp.client.title"]).toBeUndefined(); + expect(span.output.structuredContent).toEqual(result.structuredContent); + expect(span.output._meta).toBeUndefined(); + expect(span.output.content[0]._meta).toBeUndefined(); + expect(span.output.content[1].resource._meta).toBeUndefined(); + expect(JSON.stringify(span)).not.toContain("hidden"); + expect(JSON.stringify(span)).not.toContain("private=value"); + expect(result.content[0]._meta).toEqual({ token: "hidden" }); + }); + + it("uses incoming trace identity and routing instead of the active parent", async () => { + const traceId = "0123456789abcdef0123456789abcdef"; + const parentId = "0123456789abcdef"; + await traced( + async () => + handleRequest( + async () => ({ + content: [], + _meta: { + traceparent: + "00-ffffffffffffffffffffffffffffffff-ffffffffffffffff-01", + }, + }), + { + method: "tools/call", + params: { + _meta: { + traceparent: `00-${traceId}-${parentId}-01`, + baggage: "braintrust.parent=project_id:remote-project", + }, + }, + }, + ), + { name: "local-parent" }, + ); + const span = (await drainSpans()).find( + (span) => span.span_attributes.name === "tools/call", + )!; + expect(span.root_span_id).toBe(traceId); + expect(span.span_parents).toEqual([parentId]); + expect(span.project_id).toBe("remote-project"); + expect(JSON.stringify(span.output)).not.toContain("traceparent"); + }); + + it("falls back to the active parent for invalid trace context", async () => { + await traced( + async () => + handleRequest(async () => ({ content: [] }), { + method: "tools/call", + params: { _meta: { traceparent: "invalid" } }, + }), + { name: "parent" }, + ); + const spans = await drainSpans(); + const parent = spans.find( + (span) => span.span_attributes.name === "parent", + )!; + const child = spans.find( + (span) => span.span_attributes.name === "tools/call", + )!; + expect(child.span_parents).toEqual([parent.span_id]); + }); + + it("does not trace notifications, responses, or detached task lifecycles", async () => { + const transport = createProtocol().transport!; + for (const message of [ + { + method: "notifications/cancelled", + params: { requestId: 100, reason: "hidden" }, + }, + { id: 100, result: {} }, + { id: 100, method: "tasks/get" }, + { id: 100, method: "tools/call", params: { task: { ttl: 1000 } } }, + { + id: 100, + method: "tools/call", + params: { + _meta: { "io.modelcontextprotocol/related-task": { taskId: "1" } }, + }, + }, + ]) { + mcpChannels.message.invoke(() => {}, transport, [message], { transport }); + } + expect(await drainSpans()).toEqual([]); + }); + + it("retains sanitized results if attachment conversion fails", async () => { + vi.stubEnv("BRAINTRUST_CAPTURE_ATTACHMENTS", "true"); + await handleRequest( + async () => ({ + _meta: { secret: true }, + content: [ + { + type: "image", + mimeType: "image/png", + data: "not base64!", + _meta: { secret: true }, + }, + ], + }), + { method: "tools/call" }, + ); + const [span] = await drainSpans(); + expect(span.output).toEqual({ + content: [{ type: "image", mimeType: "image/png", data: "not base64!" }], + }); + }); + it("keeps concurrent handler contexts independent across awaits", async () => { + const protocol = createProtocol(); + await Promise.all( + ["first", "second"].map((name, id) => + handleRequest( + async () => { + await Promise.resolve(); + return traced(async () => ({ content: [] }), { + name: `${name}-child`, + }); + }, + { id, method: "tools/call", params: { name } }, + {}, + protocol, + ), + ), + ); + const spans = await drainSpans(); + for (const name of ["first", "second"]) { + const parent = spans.find((span) => span.span_attributes.name === name)!; + const child = spans.find( + (span) => span.span_attributes.name === `${name}-child`, + )!; + expect(child.span_parents).toEqual([parent.span_id]); + } + }); + + it("does not let a cancelled request's late response finish a reused ID", async () => { + const protocol = createProtocol(); + const transport = protocol.transport!; + const controller = new AbortController(); + let sendLate!: () => unknown; + await handleRequest( + async () => { + // Retain the original request's asynchronous context. + const late = new Promise((resolve) => { + sendLate = resolve; + }); + void late.then(() => + mcpChannels.send.invoke( + transport.send, + transport, + [{ id: 0, result: { content: [{ type: "text", text: "late" }] } }], + { transport }, + ), + ); + controller.abort(); + return { content: [] }; + }, + { method: "tools/call" }, + { signal: controller.signal }, + protocol, + ); + await drainSpans(); + mcpChannels.message.invoke( + () => {}, + transport, + [{ id: 0, method: "ping" }], + { transport }, + ); + sendLate(); + await Promise.resolve(); + const [pending] = await drainSpans(); + expect(pending.span_attributes.name).toBe("ping"); + expect(pending.metrics.end).toBeUndefined(); + mcpChannels.send.invoke( + transport.send, + transport, + [{ id: 0, result: {} }], + { transport }, + ); + const [ended] = await drainSpans(); + expect(ended.metrics.end).toEqual(expect.any(Number)); + }); + + it("preserves a failure observed before cancellation", async () => { + const protocol = createProtocol(); + const transport = protocol.transport!; + const controller = new AbortController(); + await mcpChannels.message.invoke( + async () => { + await Promise.resolve( + mcpChannels.requestHandler.invoke( + async () => { + throw new Error("independent failure"); + }, + undefined, + [ + { method: "tools/call" }, + { requestId: 0, signal: controller.signal }, + ], + { protocol }, + ), + ).catch(() => {}); + controller.abort(); + await mcpChannels.requestHandler.invoke( + async () => ({}), + undefined, + [ + { method: "tools/call" }, + { requestId: 0, signal: controller.signal }, + ], + { protocol }, + ); + }, + transport, + [{ id: 0, method: "tools/call" }], + { transport }, + ); + const [span] = await drainSpans(); + expect(span.metadata["mcp.cancelled"]).toBe(true); + expect(span.error).toContain("independent failure"); + }); + + it("does not instrument client transports", () => { + const protocol = createProtocol(); + const transport = protocol.transport!; + const send = transport.send; + mcpSDKChannels.connect.invoke(protocol.connect, protocol, [transport], {}); + expect(transport.send).toBe(send); + }); +}); diff --git a/js/src/instrumentation/plugins/mcp-plugin.ts b/js/src/instrumentation/plugins/mcp-plugin.ts new file mode 100644 index 000000000..054e33bdc --- /dev/null +++ b/js/src/instrumentation/plugins/mcp-plugin.ts @@ -0,0 +1,102 @@ +import type { + MCPProtocol, + MCPRequestHandler, +} from "../../vendor-sdk-types/mcp"; +import { BasePlugin } from "../core"; +import { unsubscribeAll } from "../core/tracing"; +import { runInstrumentation } from "../core/observe-result"; +import { + mcpChannels, + mcpSDKChannels, + mcpServerChannels, + wrapMCPProtocol, + wrapMCPTransport, + wrapMCPRequestHandler, +} from "./mcp-interceptors"; +import { + traceMCPMessage, + traceMCPSend, + traceMCPClose, + traceMCPHandler, + traceMCPProtocolError, +} from "./mcp-tracing"; + +export class MCPPlugin extends BasePlugin { + protected onEnable(): void { + this.unsubscribers.push( + ...[ + mcpChannels.connect, + mcpSDKChannels.connect, + mcpServerChannels.connect, + ].map((channel) => + channel.intercept((target, receiver, args) => { + const protocol = receiver as MCPProtocol; + // Protocol is shared with clients. Only servers have a server identity; + // the manual wrapper's channel is an explicit server opt-in. + if ( + channel === mcpChannels.connect || + protocol._serverInfo !== undefined + ) { + runInstrumentation(() => { + wrapMCPProtocol(protocol); + wrapMCPTransport(args[0], protocol); + }); + } + return Reflect.apply(target, receiver, args); + }), + ), + ...[ + mcpSDKChannels.setRequestHandler, + mcpServerChannels.setRequestHandler, + ].map((channel) => + channel.intercept((target, receiver, args) => { + const wrappedArgs = [...args]; + const index = wrappedArgs.length - 1; + if (typeof wrappedArgs[index] === "function") { + wrappedArgs[index] = wrapMCPRequestHandler( + wrappedArgs[index] as MCPRequestHandler, + receiver as MCPProtocol, + ); + } + return Reflect.apply(target, receiver, wrappedArgs); + }), + ), + mcpChannels.message.intercept((target, receiver, args, { transport }) => + traceMCPMessage( + () => Reflect.apply(target, receiver, args), + transport, + args[0], + ), + ), + mcpChannels.send.intercept((target, receiver, args, { transport }) => + traceMCPSend( + () => Reflect.apply(target, receiver, args), + transport, + args[0], + ), + ), + mcpChannels.close.intercept((target, receiver, args, { transport }) => + traceMCPClose(() => Reflect.apply(target, receiver, args), transport), + ), + mcpChannels.requestHandler.intercept( + (target, receiver, args, { protocol }) => + traceMCPHandler( + () => Reflect.apply(target, receiver, args), + protocol, + args[0], + args[1], + ), + ), + mcpChannels.protocolError.intercept((target, receiver, args) => + traceMCPProtocolError( + () => Reflect.apply(target, receiver, args), + args[0], + ), + ), + ); + } + + protected onDisable(): void { + this.unsubscribers = unsubscribeAll(this.unsubscribers); + } +} diff --git a/js/src/instrumentation/plugins/mcp-tracing.ts b/js/src/instrumentation/plugins/mcp-tracing.ts new file mode 100644 index 000000000..f1045b428 --- /dev/null +++ b/js/src/instrumentation/plugins/mcp-tracing.ts @@ -0,0 +1,420 @@ +import { v4 as uuidv4 } from "uuid"; +import { automaticAttachmentOriginals } from "../../automatic-attachments"; +import { SpanTypeAttribute, isObject } from "../../../util/index"; +import iso, { type IsoAsyncLocalStorage } from "../../isomorph"; +import { + Attachment, + NOOP_SPAN, + extractTraceContextFromHeaders, + startSpan, + withCurrent, + type Span, +} from "../../logger"; +import { + INSTRUMENTATION_NAMES, + withSpanInstrumentationName, +} from "../../span-origin"; +import type { + MCPTransport, + MCPRequest, + MCPRequestHandlerExtra, + MCPProtocol, +} from "../../vendor-sdk-types/mcp"; +import { + convertDataToBlob, + getExtensionFromMediaType, + isAutoCaptureAttachmentsEnabled, +} from "../../wrappers/attachment-utils"; +import { isAutoInstrumentationSuppressed } from "../auto-instrumentation-suppression"; +import { observeResult, runInstrumentation } from "../core/observe-result"; + +type PendingRequest = { + span: Span; + transport: MCPTransport; + id: string | number; + method: string; + ended: boolean; + handlerStarted?: boolean; + failure?: unknown; +}; +const connections = new WeakMap< + MCPTransport, + Map +>(); +const sessionIds = new WeakMap(); +let requestContext: IsoAsyncLocalStorage | undefined; + +function finish( + request: PendingRequest, + error?: unknown, + cancelled = false, +): void { + if (request.ended) return; + request.ended = true; + if (connections.get(request.transport)?.get(request.id) === request) { + connections.get(request.transport)!.delete(request.id); + } + const failure = request.failure ?? error; + request.span.log({ + ...(failure !== undefined ? { error: failure } : {}), + ...(cancelled ? { metadata: { "mcp.cancelled": true } } : {}), + }); + request.span.end(); +} + +function protocolMetadata( + meta: unknown, + direction: "request" | "response", +): Record { + if (!isObject(meta)) return {}; + const metadata: Record = {}; + if ( + direction === "request" && + typeof meta["io.modelcontextprotocol/protocolVersion"] === "string" + ) { + metadata["mcp.protocol_version"] = + meta["io.modelcontextprotocol/protocolVersion"]; + } + const side = direction === "request" ? "client" : "server"; + const info = meta[`io.modelcontextprotocol/${side}Info`]; + if (isObject(info)) { + for (const key of ["name", "title", "version"]) { + const value = info[key]; + if (typeof value === "string") metadata[`mcp.${side}.${key}`] = value; + } + } + return metadata; +} + +/** Observe transport receipt, before protocol validation and dispatch. */ +export function traceMCPMessage( + call: () => T, + transport: MCPTransport, + message: unknown, +): T { + const request = runInstrumentation(() => { + if ( + isAutoInstrumentationSuppressed() || + !isObject(message) || + typeof message.method !== "string" || + (typeof message.id !== "string" && typeof message.id !== "number") + ) + return; + const params = isObject(message.params) ? message.params : {}; + const meta = isObject(params._meta) ? params._meta : {}; + // Task submission/polling has a separate result lifecycle and is excluded. + if ( + message.method.startsWith("tasks/") || + params.task !== undefined || + meta["io.modelcontextprotocol/related-task"] !== undefined + ) + return; + let pending = connections.get(transport); + if (!pending) connections.set(transport, (pending = new Map())); + // Multiple instrumentation layers can see the same delivery. + const active = requestContext?.getStore(); + if ( + active && + !active.ended && + active.transport === transport && + active.id === message.id + ) + return; + let sessionId = transport.sessionId || sessionIds.get(transport); + if (!sessionId) sessionIds.set(transport, (sessionId = uuidv4())); + const method = message.method; + const metadata = { + "mcp.method": method, + "mcp.request_id": message.id, + "mcp.session_id": sessionId, + ...(method === "prompts/get" && typeof params.name === "string" + ? { "mcp.prompt_name": params.name } + : {}), + ...(method === "resources/read" && typeof params.uri === "string" + ? { "mcp.resource_uri": params.uri } + : {}), + ...protocolMetadata(meta, "request"), + }; + const carrier: Record = {}; + for (const key of ["traceparent", "tracestate", "baggage"]) { + const value = meta[key]; + if (typeof value === "string") carrier[key] = value; + } + const parent = extractTraceContextFromHeaders(carrier); + const spanArgs = withSpanInstrumentationName( + { + name: + method === "tools/call" && + typeof params.name === "string" && + params.name + ? params.name + : method, + type: + method === "tools/call" + ? SpanTypeAttribute.TOOL + : SpanTypeAttribute.FUNCTION, + parent, + event: { + metadata, + ...(method === "tools/call" || method === "prompts/get" + ? { input: params.arguments } + : method === "resources/read" && params.uri !== undefined + ? { input: { uri: params.uri } } + : {}), + }, + }, + INSTRUMENTATION_NAMES.MCP, + ); + const span = parent + ? withCurrent(NOOP_SPAN, () => startSpan(spanArgs)) + : startSpan(spanArgs); + const state: PendingRequest = { + span, + transport, + id: message.id, + method, + ended: false, + }; + pending.set(message.id, state); + return state; + }); + if (!request) return call(); + requestContext ??= iso.newAsyncLocalStorage(); + return requestContext.run(request, () => + withCurrent(request.span, () => { + try { + return observeResult( + call(), + () => {}, + (error) => finish(request, error), + ); + } catch (error) { + finish(request, error); + throw error; + } + }), + ); +} + +/** Keep requests open until the response send settles, preserving its promise. */ +export function traceMCPSend( + call: () => T, + transport: MCPTransport, + message: unknown, +): T { + const request = runInstrumentation(() => { + if ( + !isObject(message) || + typeof message.method === "string" || + (typeof message.id !== "string" && typeof message.id !== "number") + ) + return; + const active = requestContext?.getStore(); + const state = + active?.transport === transport && active.id === message.id + ? active + : connections.get(transport)?.get(message.id); + if (!state || state.ended) return; + runInstrumentation(() => { + if (isObject(message.error)) { + state.failure ??= new Error( + `MCP error ${message.error.code}: ${message.error.message}`, + ); + } else if ("result" in message) { + const result = message.result; + state.span.log({ + metadata: protocolMetadata( + isObject(result) ? result._meta : undefined, + "response", + ), + ...(["tools/call", "prompts/get", "resources/read"].includes( + state.method, + ) + ? { output: sanitizeResult(result) } + : {}), + }); + if (state.method === "tools/call") + state.failure ??= extractToolError(result); + } + }); + return state; + }); + if (!request) return call(); + try { + return observeResult( + call(), + () => finish(request), + (error) => finish(request, error), + ); + } catch (error) { + finish(request, error); + throw error; + } +} + +/** Handler settlement observes cancellation; it never starts another span. */ +export function traceMCPHandler( + call: () => T, + protocol: MCPProtocol, + request: MCPRequest, + extra: MCPRequestHandlerExtra, +): T { + const active = requestContext?.getStore(); + const id = extra?.mcpReq?.id ?? extra?.requestId ?? request.id; + const state = + active && active.transport === protocol.transport && active.id === id + ? active + : undefined; + if (!state || state.ended) return call(); + state.handlerStarted = true; + const signal = extra?.mcpReq?.signal ?? extra?.signal; + const settled = (error?: unknown) => { + if (state.ended) return; + if (signal?.aborted) finish(state, undefined, true); + else if (error !== undefined) state.failure ??= error; + }; + try { + return observeResult( + call(), + (result) => { + if (!state.ended && !signal?.aborted && state.method === "tools/call") + state.failure ??= extractToolError(result); + settled(); + }, + (error) => settled(error), + ); + } catch (error) { + settled(error); + throw error; + } +} + +export function traceMCPClose(call: () => T, transport: MCPTransport): T { + for (const request of connections.get(transport)?.values() ?? []) { + finish( + request, + new Error("MCP connection closed before the response completed"), + ); + } + connections.delete(transport); + sessionIds.delete(transport); + return call(); +} + +export function traceMCPProtocolError(call: () => T, error: unknown): T { + const request = requestContext?.getStore(); + if (request && !request.ended) { + request.failure ??= error; + // Encoding errors after a handler are followed by an error response. + // Dispatch drops reported before any handler have no response to wait for. + if (!request.handlerStarted) finish(request); + } + return call(); +} + +function extractToolError(result: unknown): unknown { + if (!isObject(result) || result.isError !== true) { + return undefined; + } + + const text = Array.isArray(result.content) + ? result.content + .flatMap((block) => + isObject(block) && + block.type === "text" && + typeof block.text === "string" + ? [block.text] + : [], + ) + .join("\n") + : ""; + return text || "Tool returned an error result"; +} + +function sanitizeResult(result: unknown): unknown { + if (!isObject(result)) { + return result; + } + return sanitizeContent(result, isAutoCaptureAttachmentsEnabled(), 0); +} + +/** + * Replace inline base64 media in MCP content blocks and resource contents. + * Images and resource blobs become attachments only when attachment capture + * is enabled. Audio and video bytes are never captured. + */ +function sanitizeContent( + value: unknown, + captureAttachments: boolean, + depth: number, + protocolContent = true, +): unknown { + if (depth >= 128) { + return ""; + } + if (Array.isArray(value)) { + return value.map((item) => + sanitizeContent(item, captureAttachments, depth + 1, protocolContent), + ); + } + if (!isObject(value)) { + return value; + } + + const mediaKey = + (value.type === "image" || value.type === "audio") && + typeof value.data === "string" + ? "data" + : typeof value.uri === "string" && typeof value.blob === "string" + ? "blob" + : undefined; + + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !protocolContent || key !== "_meta") + .map(([key, item]) => [ + key, + key === mediaKey + ? toAttachment( + item as string, + value.mimeType, + captureAttachments && value.type !== "audio", + ) + : sanitizeContent( + item, + captureAttachments, + depth + 1, + protocolContent && key !== "structuredContent", + ), + ]), + ); +} + +function toAttachment( + data: string, + mimeType: unknown, + captureAttachments: boolean, +): Attachment | string { + // Values that cannot be decoded stay unchanged when capture is enabled. + const contentType = + typeof mimeType === "string" && mimeType + ? mimeType + : "application/octet-stream"; + if ( + !captureAttachments || + contentType.startsWith("audio/") || + contentType.startsWith("video/") + ) { + return ""; + } + + const blob = convertDataToBlob(data, contentType); + if (!blob) return data; + const attachment = new Attachment({ + data: blob, + filename: `${contentType.split("/")[0]}.${getExtensionFromMediaType(contentType)}`, + contentType, + }); + automaticAttachmentOriginals.set(attachment, data); + return attachment; +} diff --git a/js/src/span-origin.ts b/js/src/span-origin.ts index cdf8b1cb3..19998e780 100644 --- a/js/src/span-origin.ts +++ b/js/src/span-origin.ts @@ -32,6 +32,7 @@ export const INSTRUMENTATION_NAMES = { LANGSMITH: "langsmith", LIVEKIT: "livekit", MASTRA: "mastra", + MCP: "mcp", MISTRAL: "mistral", LANGGRAPH_SDK: "langgraph-sdk", OLLAMA: "ollama", diff --git a/js/src/vendor-sdk-types/mcp.ts b/js/src/vendor-sdk-types/mcp.ts new file mode 100644 index 000000000..dbb25b5f3 --- /dev/null +++ b/js/src/vendor-sdk-types/mcp.ts @@ -0,0 +1,43 @@ +/** Vendored transport/protocol subset shared by MCP TypeScript SDK v1 and v2. */ +export type MCPRequest = { + id?: string | number; + method: string; + params?: { + name?: unknown; + arguments?: unknown; + uri?: unknown; + _meta?: unknown; + [key: string]: unknown; + }; +}; + +export type MCPRequestHandlerExtra = { + sessionId?: string; + requestId?: string | number; + signal?: AbortSignal; + mcpReq?: { id?: string | number; signal?: AbortSignal }; + [key: string]: unknown; +}; + +export type MCPRequestHandler = ( + request: MCPRequest, + extra: MCPRequestHandlerExtra, +) => unknown; + +export type MCPTransport = { + sessionId?: string; + start: (...args: unknown[]) => unknown; + send: (message: unknown, ...args: unknown[]) => unknown; + onmessage?: (message: unknown, ...args: unknown[]) => unknown; + onclose?: (...args: unknown[]) => unknown; +}; + +export type MCPProtocol = { + connect: (transport: MCPTransport, ...args: unknown[]) => unknown; + setRequestHandler: (...args: unknown[]) => void; + readonly transport?: MCPTransport; + fallbackRequestHandler?: MCPRequestHandler; + _serverInfo?: unknown; + _requestHandlers?: Map; + _onerror?: (error: unknown) => unknown; +}; diff --git a/js/src/wrappers/mcp.test.ts b/js/src/wrappers/mcp.test.ts new file mode 100644 index 000000000..d95a63f98 --- /dev/null +++ b/js/src/wrappers/mcp.test.ts @@ -0,0 +1,138 @@ +import { debugLogger } from "../debug-logger"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { _exportsForTestingOnly, initLogger } from "../logger"; +import { configureNode } from "../node/config"; +import { mcpSDKChannels } from "../instrumentation/plugins/mcp-interceptors"; +import type { + MCPProtocol, + MCPRequestHandler, + MCPTransport, + MCPRequest, +} from "../vendor-sdk-types/mcp"; +import { wrapMCPServer } from "./mcp"; + +try { + configureNode(); +} catch { + // Best-effort initialization for test environments. +} + +class FakeProtocol implements MCPProtocol { + _requestHandlers = new Map(); + transport?: MCPTransport; + async connect(transport: MCPTransport) { + this.transport = transport; + transport.onmessage = async (message) => { + const request = message as MCPRequest; + const result = await this._requestHandlers.get(request.method)!(request, { + requestId: request.id, + }); + await transport.send({ jsonrpc: "2.0", id: request.id, result }); + }; + transport.onclose = () => {}; + await transport.start(); + } + + setRequestHandler(method: unknown, handler: unknown): void { + this._requestHandlers.set(method as string, handler as MCPRequestHandler); + } + + handle(method: string, params: Record = {}) { + return this.transport!.onmessage!({ id: 0, method, params }); + } +} + +describe("MCP server wrapper", () => { + let backgroundLogger: ReturnType< + typeof _exportsForTestingOnly.useTestBackgroundLogger + >; + + beforeAll(async () => { + await _exportsForTestingOnly.simulateLoginForTests(); + }); + + beforeEach(() => { + backgroundLogger = _exportsForTestingOnly.useTestBackgroundLogger(); + initLogger({ + projectName: "mcp.test.ts", + projectId: "test-project-id", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + _exportsForTestingOnly.clearTestBackgroundLogger(); + }); + + async function spanNames(): Promise { + const spans = (await backgroundLogger.drain()) as Record[]; + return spans.map((span) => span.span_attributes.name); + } + + it("traces handlers registered before and after wrapping an McpServer", async () => { + const protocol = new FakeProtocol(); + const mcpServer = { server: protocol }; + protocol.setRequestHandler("tools/call", async () => ({ content: [] })); + + expect(wrapMCPServer(mcpServer)).toBe(mcpServer); + protocol.setRequestHandler("prompts/get", async () => ({ messages: [] })); + + if (!protocol.transport) await protocol.connect({ start() {}, send() {} }); + await protocol.handle("tools/call", { name: "early" }); + if (!protocol.transport) await protocol.connect({ start() {}, send() {} }); + await protocol.handle("prompts/get", { name: "late" }); + + expect(await spanNames()).toEqual(["early", "prompts/get"]); + }); + + it("wraps a low-level server idempotently", async () => { + const protocol = new FakeProtocol(); + + wrapMCPServer(protocol); + wrapMCPServer(protocol); + protocol.setRequestHandler("tools/call", async () => ({ content: [] })); + if (!protocol.transport) await protocol.connect({ start() {}, send() {} }); + await protocol.handle("tools/call", { name: "once" }); + + expect(await spanNames()).toEqual(["once"]); + }); + + it("creates one span when auto-instrumentation also wrapped the handler", async () => { + const protocol = new FakeProtocol(); + const handler = async () => ({ content: [] }); + // Mirror the v1 registry, which stores a closure around the registered handler. + mcpSDKChannels.setRequestHandler.invoke( + (method: unknown, registered: unknown) => + protocol._requestHandlers.set(method as string, (request, extra) => + (registered as MCPRequestHandler)(request, extra), + ), + protocol, + ["tools/call", handler], + {}, + ); + + wrapMCPServer({ server: protocol }); + if (!protocol.transport) await protocol.connect({ start() {}, send() {} }); + await protocol.handle("tools/call", { name: "deduplicated" }); + + expect(await spanNames()).toEqual(["deduplicated"]); + }); + + it("returns unsupported values unchanged", () => { + const warn = vi + .spyOn(debugLogger.notice, "warn") + .mockImplementation(() => true); + const value = { connect() {} }; + + expect(wrapMCPServer(value)).toBe(value); + expect(warn).toHaveBeenCalledWith("Unsupported MCP server. Not wrapping."); + }); +}); diff --git a/js/src/wrappers/mcp.ts b/js/src/wrappers/mcp.ts new file mode 100644 index 000000000..6fb82490c --- /dev/null +++ b/js/src/wrappers/mcp.ts @@ -0,0 +1,24 @@ +import { debugLogger } from "../debug-logger"; +import { isObject } from "../../util/index"; +import { wrapMCPProtocol } from "../instrumentation/plugins/mcp-interceptors"; +import type { MCPProtocol } from "../vendor-sdk-types/mcp"; + +/** + * Trace incoming MCP requests through response sending. Accepts an `McpServer` + * or low-level `Server` from `@modelcontextprotocol/sdk` or + * `@modelcontextprotocol/server`, and returns the same instance. + */ +export function wrapMCPServer(server: T): T { + const protocol = + isObject(server) && isObject(server.server) ? server.server : server; + if ( + !isObject(protocol) || + typeof protocol.connect !== "function" || + typeof protocol.setRequestHandler !== "function" + ) { + debugLogger.notice.warn("Unsupported MCP server. Not wrapping."); + return server; + } + wrapMCPProtocol(protocol as MCPProtocol); + return server; +}