Skip to content

Commit 1dbcd6e

Browse files
committed
fix(cloudflare): Wait for async events to finish
1 parent f31b899 commit 1dbcd6e

File tree

13 files changed

+505
-1
lines changed

13 files changed

+505
-1
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "cloudflare-mcp",
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\" --log-level=$(test $CI && echo 'none' || echo 'log')",
8+
"build": "wrangler deploy --dry-run",
9+
"test": "vitest --run",
10+
"typecheck": "tsc --noEmit",
11+
"cf-typegen": "wrangler types",
12+
"test:build": "pnpm install && pnpm build",
13+
"test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod",
14+
"test:prod": "TEST_ENV=production playwright test",
15+
"test:dev": "TEST_ENV=development playwright test"
16+
},
17+
"dependencies": {
18+
"@modelcontextprotocol/sdk": "^1.22.0",
19+
"@sentry/cloudflare": "latest || *",
20+
"agents": "^0.2.23",
21+
"zod": "^3.25.76"
22+
},
23+
"devDependencies": {
24+
"@cloudflare/vitest-pool-workers": "^0.8.19",
25+
"@cloudflare/workers-types": "^4.20240725.0",
26+
"@playwright/test": "~1.50.0",
27+
"@sentry-internal/test-utils": "link:../../../test-utils",
28+
"typescript": "^5.5.2",
29+
"vitest": "~3.2.0",
30+
"wrangler": "^4.23.0",
31+
"ws": "^8.18.3"
32+
},
33+
"volta": {
34+
"extends": "../../package.json"
35+
},
36+
"pnpm": {
37+
"overrides": {
38+
"strip-literal": "~2.0.0"
39+
}
40+
}
41+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 = 38787;
9+
10+
const config = getPlaywrightConfig(
11+
{
12+
startCommand: `pnpm dev --port ${APP_PORT}`,
13+
port: APP_PORT,
14+
},
15+
{
16+
// This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize
17+
workers: '100%',
18+
retries: 0,
19+
},
20+
);
21+
22+
export default config;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time)
2+
// by running `wrangler types`
3+
4+
interface Env {
5+
E2E_TEST_DSN: '';
6+
MY_DURABLE_OBJECT: DurableObjectNamespace;
7+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Welcome to Cloudflare Workers! This is your first worker.
3+
*
4+
* - Run `npm run dev` in your terminal to start a development server
5+
* - Open a browser tab at http://localhost:8787/ to see your worker in action
6+
* - Run `npm run deploy` to publish your worker
7+
*
8+
* Bind resources to your worker in `wrangler.toml`. After adding bindings, a type definition for the
9+
* `Env` object can be regenerated with `npm run cf-typegen`.
10+
*
11+
* Learn more at https://developers.cloudflare.com/workers/
12+
*/
13+
import * as Sentry from '@sentry/cloudflare';
14+
import { createMcpHandler } from 'agents/mcp';
15+
import * as z from 'zod';
16+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17+
18+
export default Sentry.withSentry(
19+
(env: Env) => ({
20+
dsn: env.E2E_TEST_DSN,
21+
environment: 'qa', // dynamic sampling bias to keep transactions
22+
tunnel: `http://localhost:3031/`, // proxy server
23+
tracesSampleRate: 1.0,
24+
sendDefaultPii: true,
25+
debug: true,
26+
transportOptions: {
27+
// We are doing a lot of events at once in this test
28+
bufferSize: 1000,
29+
},
30+
}),
31+
{
32+
async fetch(request, env, ctx) {
33+
const server = new McpServer({
34+
name: 'cloudflare-mcp',
35+
version: '1.0.0',
36+
});
37+
38+
const span = Sentry.getActiveSpan();
39+
40+
if (span) {
41+
span.setAttribute('mcp.server.extra', ' /|\ ^._.^ /|\ ');
42+
}
43+
44+
server.registerTool(
45+
'my-tool',
46+
{
47+
title: 'My Tool',
48+
description: 'My Tool Description',
49+
inputSchema: {
50+
message: z.string(),
51+
},
52+
},
53+
async ({ message }) => {
54+
const span = Sentry.getActiveSpan();
55+
56+
// simulate a long running tool
57+
await new Promise(resolve => setTimeout(resolve, 500));
58+
59+
if (span) {
60+
span.setAttribute('mcp.tool.name', 'my-tool');
61+
span.setAttribute('mcp.tool.extra', 'ƸӜƷ');
62+
span.setAttribute('mcp.tool.input', JSON.stringify({ message }));
63+
}
64+
65+
return {
66+
content: [
67+
{
68+
type: 'text' as const,
69+
text: `Tool my-tool: ${message}`,
70+
},
71+
],
72+
};
73+
},
74+
);
75+
76+
const handler = createMcpHandler(Sentry.wrapMcpServerWithSentry(server), {
77+
route: '/mcp',
78+
});
79+
80+
return handler(request, env, ctx);
81+
},
82+
} satisfies ExportedHandler<Env>,
83+
);
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',
6+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { expect, test } from "@playwright/test";
2+
import { waitForRequest } from "@sentry-internal/test-utils";
3+
4+
test("sends spans for MCP tool calls", async ({
5+
baseURL,
6+
}) => {
7+
const spanRequestWaiter = waitForRequest("cloudflare-mcp", (event) => {
8+
const transaction = event.envelope[1][0][1];
9+
return (
10+
typeof transaction !== "string" &&
11+
"transaction" in transaction &&
12+
transaction.transaction === "POST /mcp"
13+
);
14+
});
15+
16+
const spanMcpWaiter = waitForRequest("cloudflare-mcp", (event) => {
17+
const transaction = event.envelope[1][0][1];
18+
return (
19+
typeof transaction !== "string" &&
20+
"transaction" in transaction &&
21+
transaction.transaction === "tools/call my-tool"
22+
);
23+
});
24+
25+
const response = await fetch(`${baseURL}/mcp`, {
26+
method: "POST",
27+
headers: {
28+
"Content-Type": "application/json",
29+
Accept: "application/json, text/event-stream",
30+
},
31+
body: JSON.stringify({
32+
jsonrpc: "2.0",
33+
id: 1,
34+
method: "tools/call",
35+
params: {
36+
name: "my-tool",
37+
arguments: {
38+
message: "ʕっ•ᴥ•ʔっ",
39+
},
40+
},
41+
}),
42+
});
43+
44+
expect(response.status).toBe(200);
45+
46+
const requestData = await spanRequestWaiter;
47+
const mcpData = await spanMcpWaiter;
48+
49+
const requestEvent = requestData.envelope[1][0][1];
50+
const mcpEvent = mcpData.envelope[1][0][1];
51+
52+
// Check that the events have contexts
53+
// this is for TypeScript type safety
54+
if (
55+
typeof mcpEvent === "string" ||
56+
!("contexts" in mcpEvent) ||
57+
typeof requestEvent === "string" ||
58+
!("contexts" in requestEvent)
59+
) {
60+
throw new Error("Events don't have contexts");
61+
}
62+
63+
expect(mcpEvent.contexts?.trace?.trace_id).toBe(
64+
(mcpData.envelope[0].trace as any).trace_id
65+
);
66+
expect(requestData.envelope[0].event_id).not.toBe(
67+
mcpData.envelope[0].event_id
68+
);
69+
70+
expect(requestEvent.contexts?.trace).toEqual({
71+
span_id: expect.any(String),
72+
trace_id: expect.any(String),
73+
data: expect.objectContaining({
74+
"sentry.origin": "auto.http.cloudflare",
75+
"sentry.op": "http.server",
76+
"sentry.source": "url",
77+
"sentry.sample_rate": 1,
78+
"http.request.method": "POST",
79+
"url.path": "/mcp",
80+
"url.full": "http://localhost:38787/mcp",
81+
"url.port": "38787",
82+
"url.scheme": "http:",
83+
"server.address": "localhost",
84+
"http.request.body.size": 120,
85+
"user_agent.original": "node",
86+
"http.request.header.content_type": "application/json",
87+
"network.protocol.name": "HTTP/1.1",
88+
"mcp.server.extra": " /|\ ^._.^ /|\ ",
89+
"http.response.status_code": 200,
90+
}),
91+
op: "http.server",
92+
status: "ok",
93+
origin: "auto.http.cloudflare",
94+
});
95+
96+
expect(mcpEvent.contexts?.trace).toEqual({
97+
trace_id: expect.any(String),
98+
parent_span_id: requestEvent.contexts?.trace?.span_id,
99+
span_id: expect.any(String),
100+
op: 'mcp.server',
101+
origin: "auto.function.mcp_server",
102+
data: {
103+
"sentry.origin": "auto.function.mcp_server",
104+
"sentry.op": "mcp.server",
105+
"sentry.source": "route",
106+
"mcp.transport": "WorkerTransport",
107+
"network.transport": "unknown",
108+
"network.protocol.version": "2.0",
109+
"mcp.method.name": "tools/call",
110+
"mcp.request.id": "1",
111+
"mcp.tool.name": "my-tool",
112+
"mcp.request.argument.message": '"ʕっ•ᴥ•ʔっ"',
113+
"mcp.tool.extra": "ƸӜƷ",
114+
"mcp.tool.input": '{"message":"ʕっ•ᴥ•ʔっ"}',
115+
"mcp.tool.result.content_count": 1,
116+
"mcp.tool.result.content_type": "text",
117+
"mcp.tool.result.content": "Tool my-tool: ʕっ•ᴥ•ʔっ",
118+
},
119+
});
120+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["@cloudflare/vitest-pool-workers"]
5+
},
6+
"include": ["./**/*.ts", "../worker-configuration.d.ts"],
7+
"exclude": []
8+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"compilerOptions": {
3+
/* Visit https://aka.ms/tsconfig.json to read more about this file */
4+
5+
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
6+
"target": "es2021",
7+
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
8+
"lib": ["es2021"],
9+
/* Specify what JSX code is generated. */
10+
"jsx": "react-jsx",
11+
12+
/* Specify what module code is generated. */
13+
"module": "es2022",
14+
/* Specify how TypeScript looks up a file from a given module specifier. */
15+
"moduleResolution": "Bundler",
16+
/* Enable importing .json files */
17+
"resolveJsonModule": true,
18+
19+
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
20+
"allowJs": true,
21+
/* Enable error reporting in type-checked JavaScript files. */
22+
"checkJs": false,
23+
24+
/* Disable emitting files from a compilation. */
25+
"noEmit": true,
26+
27+
/* Ensure that each file can be safely transpiled without relying on other imports. */
28+
"isolatedModules": true,
29+
/* Allow 'import x from y' when a module doesn't have a default export. */
30+
"allowSyntheticDefaultImports": true,
31+
/* Ensure that casing is correct in imports. */
32+
"forceConsistentCasingInFileNames": true,
33+
34+
/* Enable all strict type-checking options. */
35+
"strict": true,
36+
37+
/* Skip type checking all .d.ts files. */
38+
"skipLibCheck": true,
39+
"types": ["@cloudflare/workers-types/experimental"]
40+
},
41+
"exclude": ["test"],
42+
"include": ["src/**/*.ts"]
43+
}

0 commit comments

Comments
 (0)