From fc64d27ccf76196ae52cd7e750806749fdd80abe Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:06:44 +0000 Subject: [PATCH 1/5] feat(mcp): Add MCP server tracing --- e2e/config/pr-comment-scenarios.json | 23 + .../mcp-sdk-v1-latest.span-tree.json | 196 ++++ .../mcp-sdk-v1-latest.span-tree.txt | 140 +++ .../__snapshots__/mcp-sdk-v1.span-tree.json | 196 ++++ .../__snapshots__/mcp-sdk-v1.span-tree.txt | 140 +++ .../mcp-server-v2-latest.span-tree.json | 196 ++++ .../mcp-server-v2-latest.span-tree.txt | 140 +++ .../mcp-server-v2.span-tree.json | 196 ++++ .../__snapshots__/mcp-server-v2.span-tree.txt | 140 +++ .../mcp-instrumentation/assertions.ts | 152 +++ .../mcp-instrumentation/package.json | 30 + .../mcp-instrumentation/pnpm-lock.yaml | 901 ++++++++++++++++++ .../mcp-instrumentation/scenario.cjs.mjs | 8 + .../mcp-instrumentation/scenario.cjs.ts | 13 + .../mcp-instrumentation/scenario.impl.mjs | 201 ++++ .../mcp-instrumentation/scenario.mjs | 6 + .../mcp-instrumentation/scenario.test.ts | 97 ++ e2e/scenarios/mcp-instrumentation/scenario.ts | 10 + js/src/auto-instrumentations/configs/all.ts | 5 + js/src/auto-instrumentations/configs/mcp.ts | 34 + .../orchestrion-js/matcher.test.ts | 58 ++ .../orchestrion-js/matcher.ts | 21 +- .../orchestrion-js/types.ts | 3 +- js/src/exports.ts | 1 + .../instrumentation/braintrust-plugin.test.ts | 23 + js/src/instrumentation/braintrust-plugin.ts | 13 + js/src/instrumentation/config.ts | 6 + js/src/instrumentation/core/tracing.ts | 4 + .../plugins/instrumentation-names.test.ts | 8 + .../plugins/mcp-interceptors.ts | 60 ++ .../plugins/mcp-plugin.test.ts | 349 +++++++ js/src/instrumentation/plugins/mcp-plugin.ts | 272 ++++++ js/src/span-origin.ts | 1 + js/src/vendor-sdk-types/mcp.ts | 40 + js/src/wrappers/mcp.test.ts | 117 +++ js/src/wrappers/mcp.ts | 67 ++ 36 files changed, 3865 insertions(+), 2 deletions(-) create mode 100644 e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.json create mode 100644 e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.txt create mode 100644 e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.json create mode 100644 e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.txt create mode 100644 e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.json create mode 100644 e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.txt create mode 100644 e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.json create mode 100644 e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.txt create mode 100644 e2e/scenarios/mcp-instrumentation/assertions.ts create mode 100644 e2e/scenarios/mcp-instrumentation/package.json create mode 100644 e2e/scenarios/mcp-instrumentation/pnpm-lock.yaml create mode 100644 e2e/scenarios/mcp-instrumentation/scenario.cjs.mjs create mode 100644 e2e/scenarios/mcp-instrumentation/scenario.cjs.ts create mode 100644 e2e/scenarios/mcp-instrumentation/scenario.impl.mjs create mode 100644 e2e/scenarios/mcp-instrumentation/scenario.mjs create mode 100644 e2e/scenarios/mcp-instrumentation/scenario.test.ts create mode 100644 e2e/scenarios/mcp-instrumentation/scenario.ts create mode 100644 js/src/auto-instrumentations/configs/mcp.ts create mode 100644 js/src/auto-instrumentations/orchestrion-js/matcher.test.ts create mode 100644 js/src/instrumentation/plugins/mcp-interceptors.ts create mode 100644 js/src/instrumentation/plugins/mcp-plugin.test.ts create mode 100644 js/src/instrumentation/plugins/mcp-plugin.ts create mode 100644 js/src/vendor-sdk-types/mcp.ts create mode 100644 js/src/wrappers/mcp.test.ts create mode 100644 js/src/wrappers/mcp.ts 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/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..16e75b9b1 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.json @@ -0,0 +1,196 @@ +{ + "span_tree": [ + { + "name": "mcp-instrumentation-root", + "type": "task", + "children": [ + { + "name": "mcp-http-session-operation", + "children": [ + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Vienna" + }, + "output": { + "content": [ + { + "text": "Sunny in Vienna", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.session_id": "" + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Paris" + }, + "output": { + "content": [ + { + "text": "Sunny in Paris", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.session_id": "" + } + }, + { + "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": "" + } + }, + { + "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": "" + } + } + ], + "metadata": { + "operation": "http-session", + "testRunId": "" + } + }, + { + "name": "mcp-in-memory-session-operation", + "children": [ + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Tokyo" + }, + "output": { + "content": [ + { + "text": "Sunny in Tokyo", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.session_id": "" + } + }, + { + "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": "" + } + }, + { + "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": "" + }, + "error": "Alert service unavailable" + } + ], + "metadata": { + "operation": "in-memory-session", + "testRunId": "" + } + } + ], + "metadata": { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + } + ] +} 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..13bb94c44 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1-latest.span-tree.txt @@ -0,0 +1,140 @@ +span_tree: +└── mcp-instrumentation-root [task] + metadata: { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + ├── mcp-http-session-operation + │ metadata: { + │ "operation": "http-session", + │ "testRunId": "" + │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Vienna" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Vienna", + │ │ "type": "text" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.session_id": "" + │ │ } + │ │ └── weather-lookup + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Paris" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Paris", + │ │ "type": "text" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.session_id": "" + │ │ } + │ │ └── weather-lookup + │ ├── 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": "" + │ │ } + │ └── 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": "" + │ } + └── mcp-in-memory-session-operation + metadata: { + "operation": "in-memory-session", + "testRunId": "" + } + ├── get_weather [tool] + │ input: { + │ "city": "Tokyo" + │ } + │ output: { + │ "content": [ + │ { + │ "text": "Sunny in Tokyo", + │ "type": "text" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 1, + │ "mcp.session_id": "" + │ } + │ └── weather-lookup + ├── render_map [tool] + │ output: { + │ "content": [ + │ { + │ "data": "", + │ "mimeType": "image/png", + │ "type": "image" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 2, + │ "mcp.session_id": "" + │ } + └── 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": "" + } + error: "Alert service unavailable" 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..16e75b9b1 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.json @@ -0,0 +1,196 @@ +{ + "span_tree": [ + { + "name": "mcp-instrumentation-root", + "type": "task", + "children": [ + { + "name": "mcp-http-session-operation", + "children": [ + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Vienna" + }, + "output": { + "content": [ + { + "text": "Sunny in Vienna", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.session_id": "" + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Paris" + }, + "output": { + "content": [ + { + "text": "Sunny in Paris", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.session_id": "" + } + }, + { + "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": "" + } + }, + { + "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": "" + } + } + ], + "metadata": { + "operation": "http-session", + "testRunId": "" + } + }, + { + "name": "mcp-in-memory-session-operation", + "children": [ + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Tokyo" + }, + "output": { + "content": [ + { + "text": "Sunny in Tokyo", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.session_id": "" + } + }, + { + "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": "" + } + }, + { + "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": "" + }, + "error": "Alert service unavailable" + } + ], + "metadata": { + "operation": "in-memory-session", + "testRunId": "" + } + } + ], + "metadata": { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + } + ] +} 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..13bb94c44 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.txt @@ -0,0 +1,140 @@ +span_tree: +└── mcp-instrumentation-root [task] + metadata: { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + ├── mcp-http-session-operation + │ metadata: { + │ "operation": "http-session", + │ "testRunId": "" + │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Vienna" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Vienna", + │ │ "type": "text" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.session_id": "" + │ │ } + │ │ └── weather-lookup + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Paris" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Paris", + │ │ "type": "text" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.session_id": "" + │ │ } + │ │ └── weather-lookup + │ ├── 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": "" + │ │ } + │ └── 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": "" + │ } + └── mcp-in-memory-session-operation + metadata: { + "operation": "in-memory-session", + "testRunId": "" + } + ├── get_weather [tool] + │ input: { + │ "city": "Tokyo" + │ } + │ output: { + │ "content": [ + │ { + │ "text": "Sunny in Tokyo", + │ "type": "text" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 1, + │ "mcp.session_id": "" + │ } + │ └── weather-lookup + ├── render_map [tool] + │ output: { + │ "content": [ + │ { + │ "data": "", + │ "mimeType": "image/png", + │ "type": "image" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 2, + │ "mcp.session_id": "" + │ } + └── 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": "" + } + error: "Alert service unavailable" 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..16e75b9b1 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.json @@ -0,0 +1,196 @@ +{ + "span_tree": [ + { + "name": "mcp-instrumentation-root", + "type": "task", + "children": [ + { + "name": "mcp-http-session-operation", + "children": [ + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Vienna" + }, + "output": { + "content": [ + { + "text": "Sunny in Vienna", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.session_id": "" + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Paris" + }, + "output": { + "content": [ + { + "text": "Sunny in Paris", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.session_id": "" + } + }, + { + "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": "" + } + }, + { + "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": "" + } + } + ], + "metadata": { + "operation": "http-session", + "testRunId": "" + } + }, + { + "name": "mcp-in-memory-session-operation", + "children": [ + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Tokyo" + }, + "output": { + "content": [ + { + "text": "Sunny in Tokyo", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.session_id": "" + } + }, + { + "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": "" + } + }, + { + "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": "" + }, + "error": "Alert service unavailable" + } + ], + "metadata": { + "operation": "in-memory-session", + "testRunId": "" + } + } + ], + "metadata": { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + } + ] +} 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..13bb94c44 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2-latest.span-tree.txt @@ -0,0 +1,140 @@ +span_tree: +└── mcp-instrumentation-root [task] + metadata: { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + ├── mcp-http-session-operation + │ metadata: { + │ "operation": "http-session", + │ "testRunId": "" + │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Vienna" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Vienna", + │ │ "type": "text" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.session_id": "" + │ │ } + │ │ └── weather-lookup + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Paris" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Paris", + │ │ "type": "text" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.session_id": "" + │ │ } + │ │ └── weather-lookup + │ ├── 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": "" + │ │ } + │ └── 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": "" + │ } + └── mcp-in-memory-session-operation + metadata: { + "operation": "in-memory-session", + "testRunId": "" + } + ├── get_weather [tool] + │ input: { + │ "city": "Tokyo" + │ } + │ output: { + │ "content": [ + │ { + │ "text": "Sunny in Tokyo", + │ "type": "text" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 1, + │ "mcp.session_id": "" + │ } + │ └── weather-lookup + ├── render_map [tool] + │ output: { + │ "content": [ + │ { + │ "data": "", + │ "mimeType": "image/png", + │ "type": "image" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 2, + │ "mcp.session_id": "" + │ } + └── 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": "" + } + error: "Alert service unavailable" 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..16e75b9b1 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.json @@ -0,0 +1,196 @@ +{ + "span_tree": [ + { + "name": "mcp-instrumentation-root", + "type": "task", + "children": [ + { + "name": "mcp-http-session-operation", + "children": [ + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Vienna" + }, + "output": { + "content": [ + { + "text": "Sunny in Vienna", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.session_id": "" + } + }, + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Paris" + }, + "output": { + "content": [ + { + "text": "Sunny in Paris", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 2, + "mcp.session_id": "" + } + }, + { + "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": "" + } + }, + { + "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": "" + } + } + ], + "metadata": { + "operation": "http-session", + "testRunId": "" + } + }, + { + "name": "mcp-in-memory-session-operation", + "children": [ + { + "name": "get_weather", + "type": "tool", + "children": [ + { + "name": "weather-lookup", + "children": [] + } + ], + "input": { + "city": "Tokyo" + }, + "output": { + "content": [ + { + "text": "Sunny in Tokyo", + "type": "text" + } + ] + }, + "metadata": { + "mcp.method": "tools/call", + "mcp.request_id": 1, + "mcp.session_id": "" + } + }, + { + "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": "" + } + }, + { + "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": "" + }, + "error": "Alert service unavailable" + } + ], + "metadata": { + "operation": "in-memory-session", + "testRunId": "" + } + } + ], + "metadata": { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + } + ] +} 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..13bb94c44 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.txt @@ -0,0 +1,140 @@ +span_tree: +└── mcp-instrumentation-root [task] + metadata: { + "scenario": "mcp-instrumentation", + "testRunId": "" + } + ├── mcp-http-session-operation + │ metadata: { + │ "operation": "http-session", + │ "testRunId": "" + │ } + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Vienna" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Vienna", + │ │ "type": "text" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 1, + │ │ "mcp.session_id": "" + │ │ } + │ │ └── weather-lookup + │ ├── get_weather [tool] + │ │ input: { + │ │ "city": "Paris" + │ │ } + │ │ output: { + │ │ "content": [ + │ │ { + │ │ "text": "Sunny in Paris", + │ │ "type": "text" + │ │ } + │ │ ] + │ │ } + │ │ metadata: { + │ │ "mcp.method": "tools/call", + │ │ "mcp.request_id": 2, + │ │ "mcp.session_id": "" + │ │ } + │ │ └── weather-lookup + │ ├── 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": "" + │ │ } + │ └── 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": "" + │ } + └── mcp-in-memory-session-operation + metadata: { + "operation": "in-memory-session", + "testRunId": "" + } + ├── get_weather [tool] + │ input: { + │ "city": "Tokyo" + │ } + │ output: { + │ "content": [ + │ { + │ "text": "Sunny in Tokyo", + │ "type": "text" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 1, + │ "mcp.session_id": "" + │ } + │ └── weather-lookup + ├── render_map [tool] + │ output: { + │ "content": [ + │ { + │ "data": "", + │ "mimeType": "image/png", + │ "type": "image" + │ } + │ ] + │ } + │ metadata: { + │ "mcp.method": "tools/call", + │ "mcp.request_id": 2, + │ "mcp.session_id": "" + │ } + └── 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": "" + } + error: "Alert service unavailable" diff --git a/e2e/scenarios/mcp-instrumentation/assertions.ts b/e2e/scenarios/mcp-instrumentation/assertions.ts new file mode 100644 index 000000000..9b67c3673 --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/assertions.ts @@ -0,0 +1,152 @@ +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 } 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("matches the span tree snapshot", async ({ expect }) => { + await matchSpanTreeSnapshot(events, 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..ac9278fda --- /dev/null +++ b/e2e/scenarios/mcp-instrumentation/scenario.impl.mjs @@ -0,0 +1,201 @@ +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}` }], + }), + { 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" } }); + await client.callTool({ name: "render_map", arguments: {} }); + await client.callTool({ name: "get_alerts", arguments: {} }); + + 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), + ); + }, + 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..73f21cdec --- /dev/null +++ b/js/src/auto-instrumentations/configs/mcp.ts @@ -0,0 +1,34 @@ +import type { InstrumentationConfig } from "../orchestrion-js"; +import { + mcpSDKChannels, + mcpServerChannels, +} from "../../instrumentation/plugins/mcp-interceptors"; + +const functionQuery = { + className: "Protocol", + methodName: "setRequestHandler", +}; + +export const mcpConfigs: InstrumentationConfig[] = [ + ...["dist/esm/shared/protocol.js", "dist/cjs/shared/protocol.js"].map( + (filePath) => ({ + channelName: mcpSDKChannels.setRequestHandler.channelName, + module: { + name: "@modelcontextprotocol/sdk", + versionRange: ">=1.2.0 <2.0.0", + filePath, + }, + functionQuery, + }), + ), + // v2 bundles `Protocol` into a content-hashed chunk renamed on every release. + ...["dist/src-*.mjs", "dist/src-*.cjs"].map((filePath) => ({ + channelName: mcpServerChannels.setRequestHandler.channelName, + module: { + name: "@modelcontextprotocol/server", + versionRange: ">=2.0.0 <3.0.0", + filePath, + }, + functionQuery, + })), +]; 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/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/core/tracing.ts b/js/src/instrumentation/core/tracing.ts index 2b374434b..edcb1c106 100644 --- a/js/src/instrumentation/core/tracing.ts +++ b/js/src/instrumentation/core/tracing.ts @@ -48,6 +48,8 @@ type AsyncChannelSpanConfig = result: ResultOf, endEvent?: AsyncEndOf, ) => unknown; + /** Report a failure that the callee returned instead of throwing. */ + extractError?: (result: ResultOf) => unknown; extractMetrics: ( result: ResultOf, startTime?: number, @@ -353,9 +355,11 @@ export function traceAsyncCall( asyncEndEvent.result, asyncEndEvent, ); + const error = config.extractError?.(asyncEndEvent.result); span.log({ output, + ...(error !== undefined ? { error } : {}), ...(normalizeMetadata(metadata) !== undefined ? { metadata: normalizeMetadata(metadata) } : {}), 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..23594bc1c --- /dev/null +++ b/js/src/instrumentation/plugins/mcp-interceptors.ts @@ -0,0 +1,60 @@ +import { channel, defineInterceptor } from "../core/interceptor-definitions"; + +import type { + MCPProtocol, + MCPRequest, + MCPRequestHandler, + MCPRequestHandlerExtra, +} from "../../vendor-sdk-types/mcp"; + +const setRequestHandlerChannel = channel({ + channelName: "Protocol.setRequestHandler", +}); + +export const mcpSDKChannels = defineInterceptor("@modelcontextprotocol/sdk", { + setRequestHandler: setRequestHandlerChannel, +}); + +export const mcpServerChannels = defineInterceptor( + "@modelcontextprotocol/server", + { + setRequestHandler: setRequestHandlerChannel, + }, +); + +export const mcpChannels = defineInterceptor("mcp", { + requestHandler: channel< + [MCPRequest, MCPRequestHandlerExtra], + unknown, + { protocol: MCPProtocol } + >({ + channelName: "requestHandler", + }), +}); + +const requestHandlerWrappers = new WeakSet(); +const protocolsWithWrappedHandlers = new WeakSet(); + +/** Whether request handlers of `protocol` already pass through the `requestHandler` hook. */ +export function hasWrappedMCPRequestHandlers(protocol: MCPProtocol): boolean { + return protocolsWithWrappedHandlers.has(protocol); +} + +/** Route every invocation of a registered request handler through the `requestHandler` hook. */ +export function wrapMCPRequestHandler( + handler: MCPRequestHandler, + protocol: MCPProtocol, +): MCPRequestHandler { + protocolsWithWrappedHandlers.add(protocol); + if (requestHandlerWrappers.has(handler)) { + return handler; + } + + const wrapped: MCPRequestHandler = function (this: unknown, request, extra) { + return mcpChannels.requestHandler.invoke(handler, this, [request, extra], { + protocol, + }); + }; + requestHandlerWrappers.add(wrapped); + return wrapped; +} 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..43da9f14a --- /dev/null +++ b/js/src/instrumentation/plugins/mcp-plugin.test.ts @@ -0,0 +1,349 @@ +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 { + setRequestHandler: () => {}, + transport, + }; +} + +function handleRequest( + handler: MCPRequestHandler, + request: MCPRequest, + extra: MCPRequestHandlerExtra = {}, + protocol: MCPProtocol = createProtocol(), +): unknown { + return mcpChannels.requestHandler.invoke( + handler, + undefined, + [request, extra], + { protocol }, + ); +} + +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__"])( + "does not trace %s requests", + async (method) => { + const result = await handleRequest(async () => ({ ok: true }), { + method, + }); + + expect(result).toEqual({ ok: true }); + expect(await drainSpans()).toEqual([]); + }, + ); + + 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 = { + 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 handlers.get("tools/call")!(request, extra); + await handlers.get("tools/call")!(request, extra); + + 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("leaves custom v2 handlers registered with schemas untouched", () => { + 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, + handler, + ); + }); +}); diff --git a/js/src/instrumentation/plugins/mcp-plugin.ts b/js/src/instrumentation/plugins/mcp-plugin.ts new file mode 100644 index 000000000..fca68dc91 --- /dev/null +++ b/js/src/instrumentation/plugins/mcp-plugin.ts @@ -0,0 +1,272 @@ +import { v4 as uuidv4 } from "uuid"; +import { SpanTypeAttribute, isObject } from "../../../util/index"; +import { Attachment } from "../../logger"; +import { INSTRUMENTATION_NAMES } from "../../span-origin"; +import type { + MCPProtocol, + MCPRequest, + MCPRequestHandler, + MCPRequestHandlerExtra, +} from "../../vendor-sdk-types/mcp"; +import { + convertDataToBlob, + getExtensionFromMediaType, + isAutoCaptureAttachmentsEnabled, +} from "../../wrappers/attachment-utils"; +import { BasePlugin } from "../core"; +import { traceAsyncCall, unsubscribeAll } from "../core/tracing"; +import { + mcpChannels, + mcpSDKChannels, + mcpServerChannels, + wrapMCPRequestHandler, +} from "./mcp-interceptors"; + +type MCPRequestParams = NonNullable; + +type MCPRequestSpan = { + type: SpanTypeAttribute; + name: (params: MCPRequestParams) => string; + input: (params: MCPRequestParams) => unknown; + metadata?: (params: MCPRequestParams) => Record; + error?: (result: unknown) => unknown; +}; + +// Only requests that feed model context. Tool spans are named after the tool; +// prompt names and resource URIs stay in metadata to keep span names stable. +// A Map keeps untrusted method names away from prototype lookups. +const REQUEST_SPANS = new Map([ + [ + "tools/call", + { + type: SpanTypeAttribute.TOOL, + name: (params) => + typeof params.name === "string" && params.name + ? params.name + : "tools/call", + input: (params) => params.arguments, + error: extractToolError, + }, + ], + [ + "prompts/get", + { + type: SpanTypeAttribute.FUNCTION, + name: () => "prompts/get", + input: (params) => params.arguments, + metadata: (params) => + typeof params.name === "string" + ? { "mcp.prompt_name": params.name } + : {}, + }, + ], + [ + "resources/read", + { + type: SpanTypeAttribute.FUNCTION, + name: () => "resources/read", + input: (params) => ({ uri: params.uri }), + metadata: (params) => + typeof params.uri === "string" + ? { "mcp.resource_uri": params.uri } + : {}, + }, + ], +]); + +export class MCPPlugin extends BasePlugin { + protected onEnable(): void { + this.unsubscribers.push( + ...[ + mcpSDKChannels.setRequestHandler, + mcpServerChannels.setRequestHandler, + ].map((channel) => + channel.intercept((target, receiver, args) => { + // v1 passes `(schema, handler)` and v2 `(method, handler)`. v2 custom + // methods pass `(method, schemas, handler)` and are never traced. + const handler = args[1]; + if (typeof handler !== "function") { + return Reflect.apply(target, receiver, args); + } + const wrappedArgs = [...args]; + wrappedArgs[1] = wrapMCPRequestHandler( + handler as MCPRequestHandler, + receiver as MCPProtocol, + ); + return Reflect.apply(target, receiver, wrappedArgs); + }), + ), + mcpChannels.requestHandler.intercept( + (target, receiver, args, additional) => { + const [request, extra] = args; + const spanConfig = + typeof request?.method === "string" + ? REQUEST_SPANS.get(request.method) + : undefined; + if (!spanConfig) { + return Reflect.apply(target, receiver, args); + } + const params = isObject(request.params) ? request.params : {}; + + return traceAsyncCall( + () => Reflect.apply(target, receiver, args), + { ...additional, arguments: args, self: receiver }, + { + name: spanConfig.name(params), + type: spanConfig.type, + instrumentationName: INSTRUMENTATION_NAMES.MCP, + extractInput: () => ({ + input: spanConfig.input(params), + metadata: { + "mcp.method": request.method, + "mcp.session_id": getSessionId(extra, additional.protocol), + ...getRequestIdMetadata(extra), + ...spanConfig.metadata?.(params), + }, + }), + extractOutput: sanitizeResult, + extractError: spanConfig.error, + extractMetrics: () => ({}), + }, + ); + }, + ), + ); + } + + protected onDisable(): void { + this.unsubscribers = unsubscribeAll(this.unsubscribers); + } +} + +const synthesizedSessionIds = new WeakMap(); + +/** + * Streamable HTTP sessions carry a transport session ID. Other transports + * (stdio, in-memory) get a stable ID per connected transport so requests on + * the same connection can still be correlated. + */ +function getSessionId( + extra: MCPRequestHandlerExtra | undefined, + protocol: MCPProtocol, +): string { + if (typeof extra?.sessionId === "string" && extra.sessionId) { + return extra.sessionId; + } + + const connection = protocol.transport ?? protocol; + let sessionId = synthesizedSessionIds.get(connection); + if (!sessionId) { + sessionId = uuidv4(); + synthesizedSessionIds.set(connection, sessionId); + } + return sessionId; +} + +function getRequestIdMetadata( + extra: MCPRequestHandlerExtra | undefined, +): Record { + const requestId = extra?.mcpReq?.id ?? extra?.requestId; + return typeof requestId === "string" || typeof requestId === "number" + ? { "mcp.request_id": requestId } + : {}; +} + +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; + } + const { _meta: _, ...rest } = result; + return sanitizeContent(rest, 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, +): unknown { + if (depth >= 128) { + return ""; + } + if (Array.isArray(value)) { + return value.map((item) => + sanitizeContent(item, captureAttachments, depth + 1), + ); + } + 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).map(([key, item]) => [ + key, + key === mediaKey + ? toAttachment( + item as string, + value.mimeType, + captureAttachments && value.type !== "audio", + ) + : sanitizeContent(item, captureAttachments, depth + 1), + ]), + ); +} + +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); + return blob + ? new Attachment({ + data: blob, + filename: `${contentType.split("/")[0]}.${getExtensionFromMediaType(contentType)}`, + contentType, + }) + : data; +} 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..da5643102 --- /dev/null +++ b/js/src/vendor-sdk-types/mcp.ts @@ -0,0 +1,40 @@ +/** + * Vendored subset of the Model Context Protocol TypeScript SDK types used by + * the server instrumentation. Covers `@modelcontextprotocol/sdk` v1 and + * `@modelcontextprotocol/server` v2, which share the `Protocol` request + * handler registry. + */ + +export type MCPRequest = { + method: string; + params?: { + name?: unknown; + arguments?: unknown; + uri?: unknown; + [key: string]: unknown; + }; +}; + +export type MCPRequestHandlerExtra = { + sessionId?: string; + /** v1 JSON-RPC request id. */ + requestId?: string | number; + /** v2 request context. */ + mcpReq?: { id?: string | number }; + [key: string]: unknown; +}; + +export type MCPRequestHandler = ( + request: MCPRequest, + extra: MCPRequestHandlerExtra, +) => unknown; + +export type MCPProtocol = { + /** + * v1: `(requestSchema, handler)`. v2: `(method, handler)` or + * `(method, schemas, handler)` for custom methods. + */ + setRequestHandler: (...args: unknown[]) => void; + readonly transport?: object; + _requestHandlers?: Map; +}; diff --git a/js/src/wrappers/mcp.test.ts b/js/src/wrappers/mcp.test.ts new file mode 100644 index 000000000..93aea16fe --- /dev/null +++ b/js/src/wrappers/mcp.test.ts @@ -0,0 +1,117 @@ +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 } 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 = {}; + + setRequestHandler(method: unknown, handler: unknown): void { + this._requestHandlers.set(method as string, handler as MCPRequestHandler); + } + + handle(method: string, params: Record = {}) { + return this._requestHandlers.get(method)!({ 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: [] })); + + await protocol.handle("tools/call", { name: "early" }); + 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: [] })); + 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 }); + 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..690b71147 --- /dev/null +++ b/js/src/wrappers/mcp.ts @@ -0,0 +1,67 @@ +import { debugLogger } from "../debug-logger"; +import { isObject } from "../../util/index"; +import { + hasWrappedMCPRequestHandlers, + wrapMCPRequestHandler, +} from "../instrumentation/plugins/mcp-interceptors"; +import type { MCPProtocol, MCPRequestHandler } from "../vendor-sdk-types/mcp"; + +const wrappedProtocols = new WeakSet(); + +/** + * Trace MCP requests handled by a server from `@modelcontextprotocol/sdk` or + * `@modelcontextprotocol/server`. Accepts an `McpServer` or a low-level + * `Server` and returns the same instance. + * + * `McpServer` registers request handlers internally, so the underlying + * protocol instance is patched in place: handlers registered before and after + * wrapping pass through the Braintrust instrumentation hooks. + */ +export function wrapMCPServer(server: T): T { + const protocol = getProtocol(server); + if (!protocol) { + debugLogger.notice.warn("Unsupported MCP server. Not wrapping."); + return server; + } + // Auto-instrumentation already routes every registered handler through the hook. + if ( + wrappedProtocols.has(protocol) || + hasWrappedMCPRequestHandlers(protocol) + ) { + return server; + } + wrappedProtocols.add(protocol); + + const setRequestHandler = protocol.setRequestHandler; + protocol.setRequestHandler = function (this: unknown, ...args: unknown[]) { + // v1 passes `(schema, handler)` and v2 `(method, handler)`. v2 custom + // methods pass `(method, schemas, handler)` and are never traced. + if (typeof args[1] === "function") { + args[1] = wrapMCPRequestHandler(args[1] as MCPRequestHandler, protocol); + } + return Reflect.apply(setRequestHandler, this, args); + }; + + // `McpServer` registers handlers for capabilities declared in its options + // during construction. + const handlers = protocol._requestHandlers; + if (handlers instanceof Map) { + for (const [method, handler] of handlers) { + handlers.set(method, wrapMCPRequestHandler(handler, protocol)); + } + } + + return server; +} + +function isProtocol(value: unknown): value is MCPProtocol { + return isObject(value) && typeof value.setRequestHandler === "function"; +} + +function getProtocol(server: unknown): MCPProtocol | undefined { + // `McpServer` exposes the underlying low-level `Server` as `server`. + if (isObject(server) && isProtocol(server.server)) { + return server.server; + } + return isProtocol(server) ? server : undefined; +} From 3ede6d1d398d33e83c1b4eee559d9ee8a97d1c61 Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:18:10 +0000 Subject: [PATCH 2/5] Update PR #2679 --- .changeset/add-mcp-server-tracing.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-mcp-server-tracing.md 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 From 1b03cb9cbdcd7c2345a360a402f42012f6b064be Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:45:04 +0000 Subject: [PATCH 3/5] Update PR #2679 --- e2e/helpers/normalize.test.ts | 27 +++++++++++++++++++++++++++ e2e/helpers/normalize.ts | 1 + 2 files changed, 28 insertions(+) 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", From e56520f0d889a6e1f7263a0faefd70f33dccfb26 Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:12:21 +0000 Subject: [PATCH 4/5] Update PR #2679 --- js/src/git-command.test.ts | 52 +++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) 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 () => { From a69a9b3a2504050637e8762780295dc3b9669c0a Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:02:59 +0000 Subject: [PATCH 5/5] Update PR #2679 --- .../mcp-sdk-v1-latest.span-tree.json | 277 ++++++++- .../mcp-sdk-v1-latest.span-tree.txt | 289 ++++++++-- .../__snapshots__/mcp-sdk-v1.span-tree.json | 277 ++++++++- .../__snapshots__/mcp-sdk-v1.span-tree.txt | 289 ++++++++-- .../mcp-server-v2-latest.span-tree.json | 277 ++++++++- .../mcp-server-v2-latest.span-tree.txt | 289 ++++++++-- .../mcp-server-v2.span-tree.json | 277 ++++++++- .../__snapshots__/mcp-server-v2.span-tree.txt | 289 ++++++++-- .../mcp-instrumentation/assertions.ts | 98 +++- .../mcp-instrumentation/scenario.impl.mjs | 133 ++++- js/src/auto-instrumentations/configs/mcp.ts | 19 +- js/src/instrumentation/core/tracing.ts | 4 - .../plugins/mcp-interceptors.ts | 187 +++++- .../plugins/mcp-plugin.test.ts | 533 +++++++++++++++++- js/src/instrumentation/plugins/mcp-plugin.ts | 306 +++------- js/src/instrumentation/plugins/mcp-tracing.ts | 420 ++++++++++++++ js/src/vendor-sdk-types/mcp.ts | 33 +- js/src/wrappers/mcp.test.ts | 27 +- js/src/wrappers/mcp.ts | 67 +-- 19 files changed, 3565 insertions(+), 526 deletions(-) create mode 100644 js/src/instrumentation/plugins/mcp-tracing.ts 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 index 16e75b9b1..dd9e0ab2f 100644 --- 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 @@ -7,13 +7,36 @@ { "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": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -25,12 +48,23 @@ "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 } }, { @@ -39,7 +73,16 @@ "children": [ { "name": "weather-lookup", - "children": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -51,12 +94,23 @@ "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 } }, { @@ -79,6 +133,10 @@ "mcp.request_id": 3, "mcp.resource_uri": "notes://forecast", "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 } }, { @@ -104,24 +162,60 @@ "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": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -133,12 +227,25 @@ "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 } }, { @@ -158,6 +265,10 @@ "mcp.method": "tools/call", "mcp.request_id": 2, "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 } }, { @@ -178,18 +289,172 @@ "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 index 13bb94c44..a9b051ee3 100644 --- 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 @@ -4,11 +4,39 @@ span_tree: "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" @@ -19,14 +47,34 @@ span_tree: │ │ "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" @@ -37,14 +85,34 @@ span_tree: │ │ "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" @@ -63,6 +131,10 @@ span_tree: │ │ "mcp.resource_uri": "notes://forecast", │ │ "mcp.session_id": "" │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } │ └── prompts/get [function] │ input: { │ "city": "Vienna" @@ -84,57 +156,210 @@ span_tree: │ "mcp.request_id": 4, │ "mcp.session_id": "" │ } - └── mcp-in-memory-session-operation + │ 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": "in-memory-session", + "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": "Tokyo" + │ "city": 42 │ } │ output: { │ "content": [ │ { - │ "text": "Sunny in Tokyo", + │ "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": 1, + │ "mcp.request_id": 103, │ "mcp.session_id": "" │ } - │ └── weather-lookup - ├── render_map [tool] - │ output: { - │ "content": [ - │ { - │ "data": "", - │ "mimeType": "image/png", - │ "type": "image" - │ } - │ ] + │ 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": 2, + │ "mcp.request_id": 104, │ "mcp.session_id": "" │ } - └── get_alerts [tool] - output: { - "content": [ - { - "text": "Alert service unavailable", - "type": "text" - } - ], - "isError": true - } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + └── acme/cancel [function] metadata: { - "mcp.method": "tools/call", - "mcp.request_id": 3, + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, "mcp.session_id": "" } - error: "Alert service unavailable" + 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 index 16e75b9b1..15db820c3 100644 --- a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.json +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.json @@ -7,13 +7,36 @@ { "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": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -25,12 +48,23 @@ "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 } }, { @@ -39,7 +73,16 @@ "children": [ { "name": "weather-lookup", - "children": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -51,12 +94,23 @@ "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 } }, { @@ -79,6 +133,10 @@ "mcp.request_id": 3, "mcp.resource_uri": "notes://forecast", "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 } }, { @@ -104,24 +162,60 @@ "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": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -133,12 +227,25 @@ "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 } }, { @@ -158,6 +265,10 @@ "mcp.method": "tools/call", "mcp.request_id": 2, "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 } }, { @@ -178,18 +289,172 @@ "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 index 13bb94c44..899d4962b 100644 --- a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.txt +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-sdk-v1.span-tree.txt @@ -4,11 +4,39 @@ span_tree: "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" @@ -19,14 +47,34 @@ span_tree: │ │ "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" @@ -37,14 +85,34 @@ span_tree: │ │ "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" @@ -63,6 +131,10 @@ span_tree: │ │ "mcp.resource_uri": "notes://forecast", │ │ "mcp.session_id": "" │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } │ └── prompts/get [function] │ input: { │ "city": "Vienna" @@ -84,57 +156,210 @@ span_tree: │ "mcp.request_id": 4, │ "mcp.session_id": "" │ } - └── mcp-in-memory-session-operation + │ 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": "in-memory-session", + "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": "Tokyo" + │ "city": 42 │ } │ output: { │ "content": [ │ { - │ "text": "Sunny in Tokyo", + │ "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": 1, + │ "mcp.request_id": 103, │ "mcp.session_id": "" │ } - │ └── weather-lookup - ├── render_map [tool] - │ output: { - │ "content": [ - │ { - │ "data": "", - │ "mimeType": "image/png", - │ "type": "image" - │ } - │ ] + │ 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": 2, + │ "mcp.request_id": 104, │ "mcp.session_id": "" │ } - └── get_alerts [tool] - output: { - "content": [ - { - "text": "Alert service unavailable", - "type": "text" - } - ], - "isError": true - } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + └── acme/cancel [function] metadata: { - "mcp.method": "tools/call", - "mcp.request_id": 3, + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, "mcp.session_id": "" } - error: "Alert service unavailable" + 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 index 16e75b9b1..1f244a9f3 100644 --- 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 @@ -7,13 +7,36 @@ { "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": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -25,12 +48,23 @@ "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 } }, { @@ -39,7 +73,16 @@ "children": [ { "name": "weather-lookup", - "children": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -51,12 +94,23 @@ "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 } }, { @@ -79,6 +133,10 @@ "mcp.request_id": 3, "mcp.resource_uri": "notes://forecast", "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 } }, { @@ -104,24 +162,60 @@ "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": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -133,12 +227,25 @@ "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 } }, { @@ -158,6 +265,10 @@ "mcp.method": "tools/call", "mcp.request_id": 2, "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 } }, { @@ -178,18 +289,172 @@ "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 index 13bb94c44..3411882f1 100644 --- 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 @@ -4,11 +4,39 @@ span_tree: "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" @@ -19,14 +47,34 @@ span_tree: │ │ "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" @@ -37,14 +85,34 @@ span_tree: │ │ "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" @@ -63,6 +131,10 @@ span_tree: │ │ "mcp.resource_uri": "notes://forecast", │ │ "mcp.session_id": "" │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } │ └── prompts/get [function] │ input: { │ "city": "Vienna" @@ -84,57 +156,210 @@ span_tree: │ "mcp.request_id": 4, │ "mcp.session_id": "" │ } - └── mcp-in-memory-session-operation + │ 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": "in-memory-session", + "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": "Tokyo" + │ "city": 42 │ } │ output: { │ "content": [ │ { - │ "text": "Sunny in Tokyo", + │ "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": 1, + │ "mcp.request_id": 103, │ "mcp.session_id": "" │ } - │ └── weather-lookup - ├── render_map [tool] - │ output: { - │ "content": [ - │ { - │ "data": "", - │ "mimeType": "image/png", - │ "type": "image" - │ } - │ ] + │ 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": 2, + │ "mcp.request_id": 104, │ "mcp.session_id": "" │ } - └── get_alerts [tool] - output: { - "content": [ - { - "text": "Alert service unavailable", - "type": "text" - } - ], - "isError": true - } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + └── acme/cancel [function] metadata: { - "mcp.method": "tools/call", - "mcp.request_id": 3, + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, "mcp.session_id": "" } - error: "Alert service unavailable" + 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 index 16e75b9b1..1f244a9f3 100644 --- a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.json +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.json @@ -7,13 +7,36 @@ { "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": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -25,12 +48,23 @@ "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 } }, { @@ -39,7 +73,16 @@ "children": [ { "name": "weather-lookup", - "children": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -51,12 +94,23 @@ "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 } }, { @@ -79,6 +133,10 @@ "mcp.request_id": 3, "mcp.resource_uri": "notes://forecast", "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 } }, { @@ -104,24 +162,60 @@ "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": [] + "children": [], + "metrics": { + "end": 0, + "start": 0 + }, + "context": { + "caller_filename": "", + "caller_functionname": "", + "caller_lineno": 0 + } } ], "input": { @@ -133,12 +227,25 @@ "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 } }, { @@ -158,6 +265,10 @@ "mcp.method": "tools/call", "mcp.request_id": 2, "mcp.session_id": "" + }, + "metrics": { + "end": 0, + "start": 0 } }, { @@ -178,18 +289,172 @@ "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 index 13bb94c44..3411882f1 100644 --- a/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.txt +++ b/e2e/scenarios/mcp-instrumentation/__snapshots__/mcp-server-v2.span-tree.txt @@ -4,11 +4,39 @@ span_tree: "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" @@ -19,14 +47,34 @@ span_tree: │ │ "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" @@ -37,14 +85,34 @@ span_tree: │ │ "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" @@ -63,6 +131,10 @@ span_tree: │ │ "mcp.resource_uri": "notes://forecast", │ │ "mcp.session_id": "" │ │ } + │ │ metrics: { + │ │ "end": 0, + │ │ "start": 0 + │ │ } │ └── prompts/get [function] │ input: { │ "city": "Vienna" @@ -84,57 +156,210 @@ span_tree: │ "mcp.request_id": 4, │ "mcp.session_id": "" │ } - └── mcp-in-memory-session-operation + │ 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": "in-memory-session", + "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": "Tokyo" + │ "city": 42 │ } │ output: { │ "content": [ │ { - │ "text": "Sunny in Tokyo", + │ "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": 1, + │ "mcp.request_id": 103, │ "mcp.session_id": "" │ } - │ └── weather-lookup - ├── render_map [tool] - │ output: { - │ "content": [ - │ { - │ "data": "", - │ "mimeType": "image/png", - │ "type": "image" - │ } - │ ] + │ 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": 2, + │ "mcp.request_id": 104, │ "mcp.session_id": "" │ } - └── get_alerts [tool] - output: { - "content": [ - { - "text": "Alert service unavailable", - "type": "text" - } - ], - "isError": true - } + │ metrics: { + │ "end": 0, + │ "start": 0 + │ } + └── acme/cancel [function] metadata: { - "mcp.method": "tools/call", - "mcp.request_id": 3, + "mcp.cancelled": true, + "mcp.method": "acme/cancel", + "mcp.request_id": 105, "mcp.session_id": "" } - error: "Alert service unavailable" + metrics: { + "end": 0, + "start": 0 + } diff --git a/e2e/scenarios/mcp-instrumentation/assertions.ts b/e2e/scenarios/mcp-instrumentation/assertions.ts index 9b67c3673..ddd906c79 100644 --- a/e2e/scenarios/mcp-instrumentation/assertions.ts +++ b/e2e/scenarios/mcp-instrumentation/assertions.ts @@ -5,7 +5,7 @@ import { withScenarioHarness, type ScenarioRunContext, } from "../../helpers/scenario-harness"; -import { matchSpanTreeSnapshot } from "../../helpers/span-tree"; +import { matchSpanTreeSnapshot, spanTreeFields } from "../../helpers/span-tree"; import { findChildSpans, findLatestSpan, @@ -143,10 +143,100 @@ export function defineMCPInstrumentationAssertions(options: { expect(alerts?.row.error).toContain("Alert service unavailable"); }); - test("matches the span tree snapshot", async ({ expect }) => { - await matchSpanTreeSnapshot(events, spanSnapshotPath, { - snapshotExpect: expect, + 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/scenario.impl.mjs b/e2e/scenarios/mcp-instrumentation/scenario.impl.mjs index ac9278fda..7866a2981 100644 --- a/e2e/scenarios/mcp-instrumentation/scenario.impl.mjs +++ b/e2e/scenarios/mcp-instrumentation/scenario.impl.mjs @@ -74,7 +74,21 @@ function createServer(mcp, decorateServer) { async ({ city }) => traced( async () => ({ - content: [{ type: "text", text: `Sunny in ${city}` }], + 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" }, ), @@ -161,7 +175,18 @@ async function runInMemorySession(mcp, decorateServer) { const client = createClient(mcp); await client.connect(clientTransport); - await client.callTool({ name: "get_weather", arguments: { city: "Tokyo" } }); + 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: {} }); @@ -169,6 +194,107 @@ async function runInMemorySession(mcp, decorateServer) { 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 } = {}, @@ -183,6 +309,9 @@ export async function runMCPInstrumentationScenario( "in-memory-session", () => runInMemorySession(mcp, decorateServer), ); + await runOperation("mcp-lifecycle-operation", "lifecycle", () => + runLifecycleSession(mcp, decorateServer), + ); }, metadata: { scenario: SCENARIO_NAME, diff --git a/js/src/auto-instrumentations/configs/mcp.ts b/js/src/auto-instrumentations/configs/mcp.ts index 73f21cdec..c1aee411c 100644 --- a/js/src/auto-instrumentations/configs/mcp.ts +++ b/js/src/auto-instrumentations/configs/mcp.ts @@ -4,31 +4,28 @@ import { mcpServerChannels, } from "../../instrumentation/plugins/mcp-interceptors"; -const functionQuery = { - className: "Protocol", - methodName: "setRequestHandler", -}; - -export const mcpConfigs: InstrumentationConfig[] = [ +export const mcpConfigs: InstrumentationConfig[] = ( + ["connect", "setRequestHandler"] as const +).flatMap((methodName) => [ ...["dist/esm/shared/protocol.js", "dist/cjs/shared/protocol.js"].map( (filePath) => ({ - channelName: mcpSDKChannels.setRequestHandler.channelName, + channelName: mcpSDKChannels[methodName].channelName, module: { name: "@modelcontextprotocol/sdk", versionRange: ">=1.2.0 <2.0.0", filePath, }, - functionQuery, + 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.setRequestHandler.channelName, + channelName: mcpServerChannels[methodName].channelName, module: { name: "@modelcontextprotocol/server", versionRange: ">=2.0.0 <3.0.0", filePath, }, - functionQuery, + functionQuery: { className: "Protocol", methodName }, })), -]; +]); diff --git a/js/src/instrumentation/core/tracing.ts b/js/src/instrumentation/core/tracing.ts index edcb1c106..2b374434b 100644 --- a/js/src/instrumentation/core/tracing.ts +++ b/js/src/instrumentation/core/tracing.ts @@ -48,8 +48,6 @@ type AsyncChannelSpanConfig = result: ResultOf, endEvent?: AsyncEndOf, ) => unknown; - /** Report a failure that the callee returned instead of throwing. */ - extractError?: (result: ResultOf) => unknown; extractMetrics: ( result: ResultOf, startTime?: number, @@ -355,11 +353,9 @@ export function traceAsyncCall( asyncEndEvent.result, asyncEndEvent, ); - const error = config.extractError?.(asyncEndEvent.result); span.log({ output, - ...(error !== undefined ? { error } : {}), ...(normalizeMetadata(metadata) !== undefined ? { metadata: normalizeMetadata(metadata) } : {}), diff --git a/js/src/instrumentation/plugins/mcp-interceptors.ts b/js/src/instrumentation/plugins/mcp-interceptors.ts index 23594bc1c..c841bfc3f 100644 --- a/js/src/instrumentation/plugins/mcp-interceptors.ts +++ b/js/src/instrumentation/plugins/mcp-interceptors.ts @@ -1,60 +1,187 @@ import { channel, defineInterceptor } from "../core/interceptor-definitions"; - import type { MCPProtocol, MCPRequest, MCPRequestHandler, MCPRequestHandlerExtra, + MCPTransport, } from "../../vendor-sdk-types/mcp"; -const setRequestHandlerChannel = channel({ - channelName: "Protocol.setRequestHandler", -}); - -export const mcpSDKChannels = defineInterceptor("@modelcontextprotocol/sdk", { - setRequestHandler: setRequestHandlerChannel, -}); - +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", - { - setRequestHandler: setRequestHandlerChannel, - }, + 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", - }), + >({ channelName: "requestHandler" }), }); -const requestHandlerWrappers = new WeakSet(); -const protocolsWithWrappedHandlers = new WeakSet(); +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); +} -/** Whether request handlers of `protocol` already pass through the `requestHandler` hook. */ -export function hasWrappedMCPRequestHandlers(protocol: MCPProtocol): boolean { - return protocolsWithWrappedHandlers.has(protocol); +/** 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(); } -/** Route every invocation of a registered request handler through the `requestHandler` hook. */ export function wrapMCPRequestHandler( handler: MCPRequestHandler, protocol: MCPProtocol, ): MCPRequestHandler { - protocolsWithWrappedHandlers.add(protocol); - if (requestHandlerWrappers.has(handler)) { - return handler; - } - - const wrapped: MCPRequestHandler = function (this: unknown, request, extra) { + 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, }); }; - requestHandlerWrappers.add(wrapped); - return wrapped; + 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 index 43da9f14a..e06f96a06 100644 --- a/js/src/instrumentation/plugins/mcp-plugin.test.ts +++ b/js/src/instrumentation/plugins/mcp-plugin.test.ts @@ -37,8 +37,9 @@ const PNG_BASE64 = function createProtocol(transport?: object): MCPProtocol { return { + connect: () => {}, setRequestHandler: () => {}, - transport, + transport: Object.assign(transport ?? {}, { start() {}, send() {} }), }; } @@ -48,11 +49,30 @@ function handleRequest( extra: MCPRequestHandlerExtra = {}, protocol: MCPProtocol = createProtocol(), ): unknown { - return mcpChannels.requestHandler.invoke( - handler, - undefined, - [request, extra], - { protocol }, + 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 }, ); } @@ -228,14 +248,20 @@ describe("MCPPlugin", () => { }); it.each(["tools/list", "initialize", "constructor", "__proto__"])( - "does not trace %s requests", + "traces %s without capturing arbitrary payloads", async (method) => { const result = await handleRequest(async () => ({ ok: true }), { method, }); expect(result).toEqual({ ok: true }); - expect(await drainSpans()).toEqual([]); + const [span] = await drainSpans(); + expect(span.span_attributes).toMatchObject({ + name: method, + type: "function", + }); + expect(span.input).toBeUndefined(); + expect(span.output).toBeUndefined(); }, ); @@ -302,6 +328,7 @@ describe("MCPPlugin", () => { async (_, hook) => { const handlers = new Map(); const protocol: MCPProtocol = { + ...createProtocol(), setRequestHandler(method: unknown, handler: unknown) { handlers.set(method as string, handler as MCPRequestHandler); }, @@ -316,8 +343,18 @@ describe("MCPPlugin", () => { // 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 handlers.get("tools/call")!(request, extra); - await handlers.get("tools/call")!(request, extra); + 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([ @@ -328,7 +365,7 @@ describe("MCPPlugin", () => { }, ); - it("leaves custom v2 handlers registered with schemas untouched", () => { + it("wraps custom v2 handlers without changing their schemas", () => { const schemas = { params: {} }; const handler = async () => ({}); const setRequestHandler = vi.fn(); @@ -343,7 +380,479 @@ describe("MCPPlugin", () => { expect(setRequestHandler).toHaveBeenCalledWith( "acme/search", schemas, - handler, + 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 index fca68dc91..054e33bdc 100644 --- a/js/src/instrumentation/plugins/mcp-plugin.ts +++ b/js/src/instrumentation/plugins/mcp-plugin.ts @@ -1,135 +1,97 @@ -import { v4 as uuidv4 } from "uuid"; -import { SpanTypeAttribute, isObject } from "../../../util/index"; -import { Attachment } from "../../logger"; -import { INSTRUMENTATION_NAMES } from "../../span-origin"; import type { MCPProtocol, - MCPRequest, MCPRequestHandler, - MCPRequestHandlerExtra, } from "../../vendor-sdk-types/mcp"; -import { - convertDataToBlob, - getExtensionFromMediaType, - isAutoCaptureAttachmentsEnabled, -} from "../../wrappers/attachment-utils"; import { BasePlugin } from "../core"; -import { traceAsyncCall, unsubscribeAll } from "../core/tracing"; +import { unsubscribeAll } from "../core/tracing"; +import { runInstrumentation } from "../core/observe-result"; import { mcpChannels, mcpSDKChannels, mcpServerChannels, + wrapMCPProtocol, + wrapMCPTransport, wrapMCPRequestHandler, } from "./mcp-interceptors"; - -type MCPRequestParams = NonNullable; - -type MCPRequestSpan = { - type: SpanTypeAttribute; - name: (params: MCPRequestParams) => string; - input: (params: MCPRequestParams) => unknown; - metadata?: (params: MCPRequestParams) => Record; - error?: (result: unknown) => unknown; -}; - -// Only requests that feed model context. Tool spans are named after the tool; -// prompt names and resource URIs stay in metadata to keep span names stable. -// A Map keeps untrusted method names away from prototype lookups. -const REQUEST_SPANS = new Map([ - [ - "tools/call", - { - type: SpanTypeAttribute.TOOL, - name: (params) => - typeof params.name === "string" && params.name - ? params.name - : "tools/call", - input: (params) => params.arguments, - error: extractToolError, - }, - ], - [ - "prompts/get", - { - type: SpanTypeAttribute.FUNCTION, - name: () => "prompts/get", - input: (params) => params.arguments, - metadata: (params) => - typeof params.name === "string" - ? { "mcp.prompt_name": params.name } - : {}, - }, - ], - [ - "resources/read", - { - type: SpanTypeAttribute.FUNCTION, - name: () => "resources/read", - input: (params) => ({ uri: params.uri }), - metadata: (params) => - typeof params.uri === "string" - ? { "mcp.resource_uri": params.uri } - : {}, - }, - ], -]); +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) => { - // v1 passes `(schema, handler)` and v2 `(method, handler)`. v2 custom - // methods pass `(method, schemas, handler)` and are never traced. - const handler = args[1]; - if (typeof handler !== "function") { - return Reflect.apply(target, receiver, args); - } const wrappedArgs = [...args]; - wrappedArgs[1] = wrapMCPRequestHandler( - handler as MCPRequestHandler, - receiver as MCPProtocol, - ); + 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, additional) => { - const [request, extra] = args; - const spanConfig = - typeof request?.method === "string" - ? REQUEST_SPANS.get(request.method) - : undefined; - if (!spanConfig) { - return Reflect.apply(target, receiver, args); - } - const params = isObject(request.params) ? request.params : {}; - - return traceAsyncCall( + (target, receiver, args, { protocol }) => + traceMCPHandler( () => Reflect.apply(target, receiver, args), - { ...additional, arguments: args, self: receiver }, - { - name: spanConfig.name(params), - type: spanConfig.type, - instrumentationName: INSTRUMENTATION_NAMES.MCP, - extractInput: () => ({ - input: spanConfig.input(params), - metadata: { - "mcp.method": request.method, - "mcp.session_id": getSessionId(extra, additional.protocol), - ...getRequestIdMetadata(extra), - ...spanConfig.metadata?.(params), - }, - }), - extractOutput: sanitizeResult, - extractError: spanConfig.error, - extractMetrics: () => ({}), - }, - ); - }, + protocol, + args[0], + args[1], + ), + ), + mcpChannels.protocolError.intercept((target, receiver, args) => + traceMCPProtocolError( + () => Reflect.apply(target, receiver, args), + args[0], + ), ), ); } @@ -138,135 +100,3 @@ export class MCPPlugin extends BasePlugin { this.unsubscribers = unsubscribeAll(this.unsubscribers); } } - -const synthesizedSessionIds = new WeakMap(); - -/** - * Streamable HTTP sessions carry a transport session ID. Other transports - * (stdio, in-memory) get a stable ID per connected transport so requests on - * the same connection can still be correlated. - */ -function getSessionId( - extra: MCPRequestHandlerExtra | undefined, - protocol: MCPProtocol, -): string { - if (typeof extra?.sessionId === "string" && extra.sessionId) { - return extra.sessionId; - } - - const connection = protocol.transport ?? protocol; - let sessionId = synthesizedSessionIds.get(connection); - if (!sessionId) { - sessionId = uuidv4(); - synthesizedSessionIds.set(connection, sessionId); - } - return sessionId; -} - -function getRequestIdMetadata( - extra: MCPRequestHandlerExtra | undefined, -): Record { - const requestId = extra?.mcpReq?.id ?? extra?.requestId; - return typeof requestId === "string" || typeof requestId === "number" - ? { "mcp.request_id": requestId } - : {}; -} - -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; - } - const { _meta: _, ...rest } = result; - return sanitizeContent(rest, 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, -): unknown { - if (depth >= 128) { - return ""; - } - if (Array.isArray(value)) { - return value.map((item) => - sanitizeContent(item, captureAttachments, depth + 1), - ); - } - 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).map(([key, item]) => [ - key, - key === mediaKey - ? toAttachment( - item as string, - value.mimeType, - captureAttachments && value.type !== "audio", - ) - : sanitizeContent(item, captureAttachments, depth + 1), - ]), - ); -} - -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); - return blob - ? new Attachment({ - data: blob, - filename: `${contentType.split("/")[0]}.${getExtensionFromMediaType(contentType)}`, - contentType, - }) - : data; -} 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/vendor-sdk-types/mcp.ts b/js/src/vendor-sdk-types/mcp.ts index da5643102..dbb25b5f3 100644 --- a/js/src/vendor-sdk-types/mcp.ts +++ b/js/src/vendor-sdk-types/mcp.ts @@ -1,26 +1,21 @@ -/** - * Vendored subset of the Model Context Protocol TypeScript SDK types used by - * the server instrumentation. Covers `@modelcontextprotocol/sdk` v1 and - * `@modelcontextprotocol/server` v2, which share the `Protocol` request - * handler registry. - */ - +/** 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; - /** v1 JSON-RPC request id. */ requestId?: string | number; - /** v2 request context. */ - mcpReq?: { id?: string | number }; + signal?: AbortSignal; + mcpReq?: { id?: string | number; signal?: AbortSignal }; [key: string]: unknown; }; @@ -29,12 +24,20 @@ export type MCPRequestHandler = ( 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 = { - /** - * v1: `(requestSchema, handler)`. v2: `(method, handler)` or - * `(method, schemas, handler)` for custom methods. - */ + connect: (transport: MCPTransport, ...args: unknown[]) => unknown; setRequestHandler: (...args: unknown[]) => void; - readonly transport?: object; + 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 index 93aea16fe..d95a63f98 100644 --- a/js/src/wrappers/mcp.test.ts +++ b/js/src/wrappers/mcp.test.ts @@ -11,7 +11,12 @@ import { import { _exportsForTestingOnly, initLogger } from "../logger"; import { configureNode } from "../node/config"; import { mcpSDKChannels } from "../instrumentation/plugins/mcp-interceptors"; -import type { MCPProtocol, MCPRequestHandler } from "../vendor-sdk-types/mcp"; +import type { + MCPProtocol, + MCPRequestHandler, + MCPTransport, + MCPRequest, +} from "../vendor-sdk-types/mcp"; import { wrapMCPServer } from "./mcp"; try { @@ -22,14 +27,26 @@ try { class FakeProtocol implements MCPProtocol { _requestHandlers = new Map(); - transport = {}; + 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._requestHandlers.get(method)!({ method, params }, {}); + return this.transport!.onmessage!({ id: 0, method, params }); } } @@ -68,7 +85,9 @@ describe("MCP server wrapper", () => { 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"]); @@ -80,6 +99,7 @@ describe("MCP server wrapper", () => { 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"]); @@ -100,6 +120,7 @@ describe("MCP server wrapper", () => { ); wrapMCPServer({ server: protocol }); + if (!protocol.transport) await protocol.connect({ start() {}, send() {} }); await protocol.handle("tools/call", { name: "deduplicated" }); expect(await spanNames()).toEqual(["deduplicated"]); diff --git a/js/src/wrappers/mcp.ts b/js/src/wrappers/mcp.ts index 690b71147..6fb82490c 100644 --- a/js/src/wrappers/mcp.ts +++ b/js/src/wrappers/mcp.ts @@ -1,67 +1,24 @@ import { debugLogger } from "../debug-logger"; import { isObject } from "../../util/index"; -import { - hasWrappedMCPRequestHandlers, - wrapMCPRequestHandler, -} from "../instrumentation/plugins/mcp-interceptors"; -import type { MCPProtocol, MCPRequestHandler } from "../vendor-sdk-types/mcp"; - -const wrappedProtocols = new WeakSet(); +import { wrapMCPProtocol } from "../instrumentation/plugins/mcp-interceptors"; +import type { MCPProtocol } from "../vendor-sdk-types/mcp"; /** - * Trace MCP requests handled by a server from `@modelcontextprotocol/sdk` or - * `@modelcontextprotocol/server`. Accepts an `McpServer` or a low-level - * `Server` and returns the same instance. - * - * `McpServer` registers request handlers internally, so the underlying - * protocol instance is patched in place: handlers registered before and after - * wrapping pass through the Braintrust instrumentation hooks. + * 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 = getProtocol(server); - if (!protocol) { - debugLogger.notice.warn("Unsupported MCP server. Not wrapping."); - return server; - } - // Auto-instrumentation already routes every registered handler through the hook. + const protocol = + isObject(server) && isObject(server.server) ? server.server : server; if ( - wrappedProtocols.has(protocol) || - hasWrappedMCPRequestHandlers(protocol) + !isObject(protocol) || + typeof protocol.connect !== "function" || + typeof protocol.setRequestHandler !== "function" ) { + debugLogger.notice.warn("Unsupported MCP server. Not wrapping."); return server; } - wrappedProtocols.add(protocol); - - const setRequestHandler = protocol.setRequestHandler; - protocol.setRequestHandler = function (this: unknown, ...args: unknown[]) { - // v1 passes `(schema, handler)` and v2 `(method, handler)`. v2 custom - // methods pass `(method, schemas, handler)` and are never traced. - if (typeof args[1] === "function") { - args[1] = wrapMCPRequestHandler(args[1] as MCPRequestHandler, protocol); - } - return Reflect.apply(setRequestHandler, this, args); - }; - - // `McpServer` registers handlers for capabilities declared in its options - // during construction. - const handlers = protocol._requestHandlers; - if (handlers instanceof Map) { - for (const [method, handler] of handlers) { - handlers.set(method, wrapMCPRequestHandler(handler, protocol)); - } - } - + wrapMCPProtocol(protocol as MCPProtocol); return server; } - -function isProtocol(value: unknown): value is MCPProtocol { - return isObject(value) && typeof value.setRequestHandler === "function"; -} - -function getProtocol(server: unknown): MCPProtocol | undefined { - // `McpServer` exposes the underlying low-level `Server` as `server`. - if (isObject(server) && isProtocol(server.server)) { - return server.server; - } - return isProtocol(server) ? server : undefined; -}