Skip to content

Commit 413899a

Browse files
JPeer264claude
andcommitted
test(cloudflare): Add e2e test for MCPAgent with DurableObject instrumentation
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. Ref: #17598 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 124dfeb commit 413899a

10 files changed

Lines changed: 287 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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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+
{
12+
startCommand: `pnpm dev --port ${APP_PORT}`,
13+
port: APP_PORT,
14+
},
15+
);
16+
17+
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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
2+
3+
export default defineWorkersConfig({
4+
test: {
5+
poolOptions: {
6+
workers: {
7+
wrangler: { configPath: './wrangler.toml' },
8+
},
9+
},
10+
},
11+
});
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)