Skip to content

Commit 866958e

Browse files
JPeer264claude
andauthored
test(cloudflare): Add e2e test for MCPAgent with DurableObject instrumentation (#20601)
closes #17598 closes [JS-927](https://linear.app/getsentry/issue/JS-927/add-tests-to-ensure-that-cloudflare-mcpagent-changes-work-with-durable) This test ensures that the Sentry SDK properly instruments MCPAgent (which extends DurableObject) from the Cloudflare agents package. It verifies that MCP tool call spans are correctly created and linked. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f1af9e1 commit 866958e

9 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "cloudflare-mcp-agent",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"deploy": "wrangler deploy",
7+
"dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"",
8+
"build": "wrangler deploy --dry-run",
9+
"typecheck": "tsc --noEmit",
10+
"cf-typegen": "wrangler types",
11+
"test:build": "pnpm install && pnpm build",
12+
"test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod",
13+
"test:prod": "TEST_ENV=production playwright test",
14+
"test:dev": "TEST_ENV=development playwright test"
15+
},
16+
"dependencies": {
17+
"@modelcontextprotocol/sdk": "^1.29.0",
18+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
19+
"agents": "0.11.9",
20+
"zod": "^4.3.6"
21+
},
22+
"devDependencies": {
23+
"@cloudflare/workers-types": "^4.20260426.0",
24+
"@playwright/test": "~1.56.0",
25+
"@sentry-internal/test-utils": "link:../../../test-utils",
26+
"typescript": "^6.0.3",
27+
"wrangler": "^4.86.0"
28+
},
29+
"volta": {
30+
"extends": "../../package.json"
31+
}
32+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
const testEnv = process.env.TEST_ENV;
3+
4+
if (!testEnv) {
5+
throw new Error('No test env defined');
6+
}
7+
8+
const APP_PORT = 38788;
9+
10+
const config = getPlaywrightConfig({
11+
startCommand: `pnpm dev --port ${APP_PORT}`,
12+
port: APP_PORT,
13+
});
14+
15+
export default config;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface Env {
2+
E2E_TEST_DSN: string;
3+
MCP_AGENT: DurableObjectNamespace;
4+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { McpAgent } from 'agents/mcp';
3+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4+
import * as z from 'zod';
5+
6+
class MyMCPAgentBase extends McpAgent<Env, unknown, Record<string, unknown>> {
7+
#mcpServer = new McpServer({
8+
name: 'cloudflare-mcp-agent',
9+
version: '1.0.0',
10+
});
11+
12+
get server() {
13+
return Sentry.wrapMcpServerWithSentry(this.#mcpServer);
14+
}
15+
16+
async init(): Promise<void> {
17+
this.#mcpServer.registerTool(
18+
'my-tool',
19+
{
20+
title: 'My Tool',
21+
description: 'My Tool Description',
22+
inputSchema: {
23+
message: z.string(),
24+
},
25+
},
26+
async ({ message }) => {
27+
const span = Sentry.getActiveSpan();
28+
29+
await new Promise(resolve => setTimeout(resolve, 500));
30+
31+
if (span) {
32+
span.setAttribute('mcp.tool.name', 'my-tool');
33+
span.setAttribute('mcp.tool.extra', 'from-mcpagent');
34+
span.setAttribute('mcp.tool.input', JSON.stringify({ message }));
35+
}
36+
37+
return {
38+
content: [
39+
{
40+
type: 'text' as const,
41+
text: `Tool my-tool: ${message}`,
42+
},
43+
],
44+
};
45+
},
46+
);
47+
}
48+
}
49+
50+
export const MyMCPAgent = Sentry.instrumentDurableObjectWithSentry(
51+
(env: Env) => ({
52+
dsn: env.E2E_TEST_DSN,
53+
environment: 'qa',
54+
tunnel: `http://localhost:3031/`,
55+
tracesSampleRate: 1.0,
56+
sendDefaultPii: true,
57+
debug: true,
58+
transportOptions: {
59+
bufferSize: 1000,
60+
},
61+
}),
62+
MyMCPAgentBase,
63+
);
64+
65+
export default Sentry.withSentry(
66+
(env: Env) => ({
67+
dsn: env.E2E_TEST_DSN,
68+
environment: 'qa',
69+
tunnel: `http://localhost:3031/`,
70+
tracesSampleRate: 1.0,
71+
sendDefaultPii: true,
72+
debug: true,
73+
transportOptions: {
74+
bufferSize: 1000,
75+
},
76+
}),
77+
MyMCPAgent.serve('/mcp', { binding: 'MCP_AGENT' }),
78+
);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'cloudflare-mcp-agent',
6+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForRequest } from '@sentry-internal/test-utils';
3+
4+
test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ baseURL }) => {
5+
const mcpToolWaiter = waitForRequest('cloudflare-mcp-agent', event => {
6+
const transaction = event.envelope[1][0][1];
7+
return (
8+
typeof transaction !== 'string' &&
9+
'transaction' in transaction &&
10+
transaction.transaction === 'tools/call my-tool'
11+
);
12+
});
13+
14+
// Step 1: Initialize the MCP session
15+
const initResponse = await fetch(`${baseURL}/mcp`, {
16+
method: 'POST',
17+
headers: {
18+
'Content-Type': 'application/json',
19+
Accept: 'application/json, text/event-stream',
20+
},
21+
body: JSON.stringify({
22+
jsonrpc: '2.0',
23+
id: 0,
24+
method: 'initialize',
25+
params: {
26+
protocolVersion: '2024-11-05',
27+
capabilities: {},
28+
clientInfo: {
29+
name: 'test-client',
30+
version: '1.0.0',
31+
},
32+
},
33+
}),
34+
});
35+
36+
expect(initResponse.status).toBe(200);
37+
const sessionId = initResponse.headers.get('Mcp-Session-Id');
38+
expect(sessionId).toBeTruthy();
39+
40+
// Step 2: Send initialized notification
41+
await fetch(`${baseURL}/mcp`, {
42+
method: 'POST',
43+
headers: {
44+
'Content-Type': 'application/json',
45+
Accept: 'application/json, text/event-stream',
46+
'Mcp-Session-Id': sessionId!,
47+
},
48+
body: JSON.stringify({
49+
jsonrpc: '2.0',
50+
method: 'notifications/initialized',
51+
}),
52+
});
53+
54+
// Step 3: Call the tool with the session ID
55+
const response = await fetch(`${baseURL}/mcp`, {
56+
method: 'POST',
57+
headers: {
58+
'Content-Type': 'application/json',
59+
Accept: 'application/json, text/event-stream',
60+
'Mcp-Session-Id': sessionId!,
61+
},
62+
body: JSON.stringify({
63+
jsonrpc: '2.0',
64+
id: 1,
65+
method: 'tools/call',
66+
params: {
67+
name: 'my-tool',
68+
arguments: {
69+
message: 'hello from MCPAgent test',
70+
},
71+
},
72+
}),
73+
});
74+
75+
expect(response.status).toBe(200);
76+
77+
const mcpData = await mcpToolWaiter;
78+
const mcpEvent = mcpData.envelope[1][0][1];
79+
80+
expect(mcpEvent.contexts?.trace?.trace_id).toBe(mcpData.envelope[0].trace.trace_id);
81+
expect(mcpEvent.contexts?.trace).toEqual({
82+
trace_id: expect.any(String),
83+
parent_span_id: expect.any(String),
84+
span_id: expect.any(String),
85+
op: 'mcp.server',
86+
origin: 'auto.function.mcp_server',
87+
data: expect.objectContaining({
88+
'sentry.origin': 'auto.function.mcp_server',
89+
'sentry.op': 'mcp.server',
90+
'mcp.method.name': 'tools/call',
91+
'mcp.tool.name': 'my-tool',
92+
'mcp.tool.extra': 'from-mcpagent',
93+
'mcp.tool.input': '{"message":"hello from MCPAgent test"}',
94+
}),
95+
});
96+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es2021",
4+
"lib": ["es2021"],
5+
"jsx": "react-jsx",
6+
"module": "es2022",
7+
"moduleResolution": "Bundler",
8+
"resolveJsonModule": true,
9+
"allowJs": true,
10+
"checkJs": false,
11+
"noEmit": true,
12+
"isolatedModules": true,
13+
"allowSyntheticDefaultImports": true,
14+
"forceConsistentCasingInFileNames": true,
15+
"strict": true,
16+
"skipLibCheck": true,
17+
"types": ["@cloudflare/workers-types/experimental"]
18+
},
19+
"exclude": ["test"],
20+
"include": ["src/**/*.ts"]
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"$schema": "node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-mcp-agent",
4+
"main": "src/index.ts",
5+
"compatibility_date": "2025-03-21",
6+
"compatibility_flags": ["nodejs_compat"],
7+
"durable_objects": {
8+
"bindings": [
9+
{
10+
"name": "MCP_AGENT",
11+
"class_name": "MyMCPAgent",
12+
},
13+
],
14+
},
15+
"migrations": [
16+
{
17+
"tag": "v1",
18+
"new_sqlite_classes": ["MyMCPAgent"],
19+
},
20+
],
21+
}

0 commit comments

Comments
 (0)