diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json new file mode 100644 index 000000000000..190582ec8d71 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json @@ -0,0 +1,32 @@ +{ + "name": "cloudflare-mcp-agent", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"", + "build": "wrangler deploy --dry-run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "agents": "0.11.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260426.0", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^6.0.3", + "wrangler": "^4.86.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts new file mode 100644 index 000000000000..5f22d56bb19c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/playwright.config.ts @@ -0,0 +1,15 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38788; + +const config = getPlaywrightConfig({ + startCommand: `pnpm dev --port ${APP_PORT}`, + port: APP_PORT, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts new file mode 100644 index 000000000000..a936f6586952 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/env.d.ts @@ -0,0 +1,4 @@ +interface Env { + E2E_TEST_DSN: string; + MCP_AGENT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts new file mode 100644 index 000000000000..964ab22cce55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts @@ -0,0 +1,78 @@ +import * as Sentry from '@sentry/cloudflare'; +import { McpAgent } from 'agents/mcp'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as z from 'zod'; + +class MyMCPAgentBase extends McpAgent> { + #mcpServer = new McpServer({ + name: 'cloudflare-mcp-agent', + version: '1.0.0', + }); + + get server() { + return Sentry.wrapMcpServerWithSentry(this.#mcpServer); + } + + async init(): Promise { + this.#mcpServer.registerTool( + 'my-tool', + { + title: 'My Tool', + description: 'My Tool Description', + inputSchema: { + message: z.string(), + }, + }, + async ({ message }) => { + const span = Sentry.getActiveSpan(); + + await new Promise(resolve => setTimeout(resolve, 500)); + + if (span) { + span.setAttribute('mcp.tool.name', 'my-tool'); + span.setAttribute('mcp.tool.extra', 'from-mcpagent'); + span.setAttribute('mcp.tool.input', JSON.stringify({ message })); + } + + return { + content: [ + { + type: 'text' as const, + text: `Tool my-tool: ${message}`, + }, + ], + }; + }, + ); + } +} + +export const MyMCPAgent = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + debug: true, + transportOptions: { + bufferSize: 1000, + }, + }), + MyMCPAgentBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + debug: true, + transportOptions: { + bufferSize: 1000, + }, + }), + MyMCPAgent.serve('/mcp', { binding: 'MCP_AGENT' }), +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs new file mode 100644 index 000000000000..946988f3fdc7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-mcp-agent', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts new file mode 100644 index 000000000000..cde74a76aa27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; + +test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ baseURL }) => { + const mcpToolWaiter = waitForRequest('cloudflare-mcp-agent', event => { + const transaction = event.envelope[1][0][1]; + return ( + typeof transaction !== 'string' && + 'transaction' in transaction && + transaction.transaction === 'tools/call my-tool' + ); + }); + + // Step 1: Initialize the MCP session + const initResponse = await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, + }), + }); + + expect(initResponse.status).toBe(200); + const sessionId = initResponse.headers.get('Mcp-Session-Id'); + expect(sessionId).toBeTruthy(); + + // Step 2: Send initialized notification + await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId!, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), + }); + + // Step 3: Call the tool with the session ID + const response = await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId!, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'my-tool', + arguments: { + message: 'hello from MCPAgent test', + }, + }, + }), + }); + + expect(response.status).toBe(200); + + const mcpData = await mcpToolWaiter; + const mcpEvent = mcpData.envelope[1][0][1]; + + expect(mcpEvent.contexts?.trace?.trace_id).toBe(mcpData.envelope[0].trace.trace_id); + expect(mcpEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + parent_span_id: expect.any(String), + span_id: expect.any(String), + op: 'mcp.server', + origin: 'auto.function.mcp_server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.op': 'mcp.server', + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'my-tool', + 'mcp.tool.extra': 'from-mcpagent', + 'mcp.tool.input': '{"message":"hello from MCPAgent test"}', + }), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json new file mode 100644 index 000000000000..2e9384f1b328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "jsx": "react-jsx", + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc new file mode 100644 index 000000000000..a29277811225 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/wrangler.jsonc @@ -0,0 +1,21 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-mcp-agent", + "main": "src/index.ts", + "compatibility_date": "2025-03-21", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { + "name": "MCP_AGENT", + "class_name": "MyMCPAgent", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MyMCPAgent"], + }, + ], +}