Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
"test:dev": "TEST_ENV=development playwright test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.24.0",
"@modelcontextprotocol/server": "2.0.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "0.3.10",
"zod": "^3.25.76"
"agents": "0.20.1",
"zod": "^4.2.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240725.0",
Expand All @@ -30,11 +30,5 @@
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
},
"pnpm": {
"overrides": {
"strip-literal": "~2.0.0",
"@modelcontextprotocol/sdk": "1.25.2"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,55 @@
* Learn more at https://developers.cloudflare.com/workers/
*/
import * as Sentry from '@sentry/cloudflare';
import { createMcpHandler } from 'agents/mcp';
import * as z from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpServer } from '@modelcontextprotocol/server';
import { createMcpHandler } from 'agents/mcp/server';
import { z } from 'zod';

function createServer() {
const server = Sentry.wrapMcpServerWithSentry(
new McpServer({
name: 'cloudflare-mcp',
version: '2.0.0',
}),
);

server.registerTool(
'my-tool',
{
title: 'My Tool',
description: 'My Tool Description',
inputSchema: z.object({
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', 'ƸӜƷ');
span.setAttribute('mcp.tool.input', JSON.stringify({ message }));
}

return {
content: [
{
type: 'text' as const,
text: `Tool my-tool: ${message}`,
},
],
};
},
);

return server;
}

const mcpHandler = createMcpHandler(createServer, {
route: '/mcp',
});

export default Sentry.withSentry(
(env: Env) => ({
Expand All @@ -31,54 +77,13 @@ export default Sentry.withSentry(
}),
{
async fetch(request, env, ctx) {
const server = new McpServer({
name: 'cloudflare-mcp',
version: '1.0.0',
});

const span = Sentry.getActiveSpan();

if (span) {
span.setAttribute('mcp.server.extra', ' /|\ ^._.^ /|\ ');
}

server.registerTool(
'my-tool',
{
title: 'My Tool',
description: 'My Tool Description',
inputSchema: {
message: z.string(),
},
},
async ({ message }) => {
const span = Sentry.getActiveSpan();

// simulate a long running tool
await new Promise(resolve => setTimeout(resolve, 500));

if (span) {
span.setAttribute('mcp.tool.name', 'my-tool');
span.setAttribute('mcp.tool.extra', 'ƸӜƷ');
span.setAttribute('mcp.tool.input', JSON.stringify({ message }));
}

return {
content: [
{
type: 'text' as const,
text: `Tool my-tool: ${message}`,
},
],
};
},
);

const handler = createMcpHandler(Sentry.wrapMcpServerWithSentry(server), {
route: '/mcp',
});

return handler(request, env, ctx);
return mcpHandler(request, env, ctx);
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -1,111 +1,164 @@
import { expect, test } from '@playwright/test';
import { waitForRequest } from '@sentry-internal/test-utils';

test('sends spans for MCP tool calls', async ({ baseURL }) => {
const spanRequestWaiter = waitForRequest('cloudflare-mcp', event => {
const transaction = event.envelope[1][0][1];
return typeof transaction !== 'string' && 'transaction' in transaction && transaction.transaction === 'POST /mcp';
});
const APP_NAME = 'cloudflare-mcp';

function getTransaction(eventData: Awaited<ReturnType<typeof waitForRequest>>) {
const event = eventData.envelope[1][0][1];
return typeof event !== 'string' && 'transaction' in event ? event : undefined;
}

function requireTransaction(eventData: Awaited<ReturnType<typeof waitForRequest>>) {
const event = getTransaction(eventData);
if (!event) {
throw new Error('Expected a transaction event');
}
return event;
}

test.describe.configure({ mode: 'serial' });

const spanMcpWaiter = waitForRequest('cloudflare-mcp', event => {
const transaction = event.envelope[1][0][1];
test('sends spans for MCP 2026-07-28 tool calls', async ({ baseURL }) => {
const url = `${baseURL}/mcp?protocol=modern`;
const requestWaiter = waitForRequest(APP_NAME, eventData => {
const event = getTransaction(eventData);
return event?.transaction === 'POST /mcp' && event.contexts?.trace?.data?.['url.full'] === url;
});
const mcpWaiter = waitForRequest(APP_NAME, eventData => {
const event = getTransaction(eventData);
return (
typeof transaction !== 'string' &&
'transaction' in transaction &&
transaction.transaction === 'tools/call my-tool'
event?.transaction === 'tools/call my-tool' &&
event.contexts?.trace?.data?.['mcp.protocol.version'] === '2026-07-28'
);
});

const response = await fetch(`${baseURL}/mcp`, {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': '2026-07-28',
'Mcp-Method': 'tools/call',
'Mcp-Name': 'my-tool',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
id: 'modern-tool-call',
method: 'tools/call',
params: {
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientInfo': {
name: 'cloudflare-modern-client',
version: '2.0.0',
},
'io.modelcontextprotocol/clientCapabilities': {},
},
name: 'my-tool',
arguments: {
message: 'ʕっ•ᴥ•ʔっ',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not my bear 😭

message: 'modern protocol request',
},
},
}),
});

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
jsonrpc: '2.0',
id: 'modern-tool-call',
result: {
resultType: 'complete',
content: [{ type: 'text', text: 'Tool my-tool: modern protocol request' }],
},
});

const requestData = await spanRequestWaiter;
const mcpData = await spanMcpWaiter;

const requestEvent = requestData.envelope[1][0][1];
const mcpEvent = mcpData.envelope[1][0][1];

// Check that the events have contexts
// this is for TypeScript type safety
if (
typeof mcpEvent === 'string' ||
!('contexts' in mcpEvent) ||
typeof requestEvent === 'string' ||
!('contexts' in requestEvent)
) {
throw new Error("Events don't have contexts");
}
const requestData = await requestWaiter;
const mcpData = await mcpWaiter;
const requestEvent = requireTransaction(requestData);
const mcpEvent = requireTransaction(mcpData);
const requestTrace = requestEvent.contexts?.trace;
const mcpTrace = mcpEvent.contexts?.trace;

expect(mcpEvent.contexts?.trace?.trace_id).toBe((mcpData.envelope[0].trace as any).trace_id);
expect(requestTrace?.op).toBe('http.server');
expect(requestTrace?.origin).toBe('auto.http.cloudflare');
expect(requestTrace?.status).toBe('ok');
expect(requestTrace?.data?.['sentry.origin']).toBe('auto.http.cloudflare');
expect(requestTrace?.data?.['sentry.op']).toBe('http.server');
expect(requestTrace?.data?.['sentry.source']).toBe('url');
expect(requestTrace?.data?.['http.request.method']).toBe('POST');
expect(requestTrace?.data?.['url.path']).toBe('/mcp');
expect(requestTrace?.data?.['url.full']).toBe(url);
expect(requestTrace?.data?.['url.port']).toBe('38787');
expect(requestTrace?.data?.['url.scheme']).toBe('http:');
expect(requestTrace?.data?.['server.address']).toBe('localhost');
expect(requestTrace?.data?.['http.request.body.size']).toBe(345);
expect(requestTrace?.data?.['user_agent.original']).toBe('node');
expect(requestTrace?.data?.['http.request.header.content_type']).toBe('application/json');
expect(requestTrace?.data?.['network.protocol.name']).toBe('HTTP/1.1');
expect(requestTrace?.data?.['http.response.status_code']).toBe(200);
expect(requestTrace?.data?.['mcp.server.extra']).toBe(' /|\ ^._.^ /|\ ');
expect(mcpTrace?.trace_id).toBe(requestTrace?.trace_id);
expect(mcpTrace?.trace_id).toBe((mcpData.envelope[0].trace as { trace_id: string }).trace_id);
expect(mcpTrace?.parent_span_id).toBe(requestTrace?.span_id);
expect(requestData.envelope[0].event_id).not.toBe(mcpData.envelope[0].event_id);
expect(mcpTrace?.op).toBe('mcp.server');
expect(mcpTrace?.origin).toBe('auto.function.mcp_server');
expect(mcpTrace?.status).toBe('ok');
expect(mcpTrace?.data?.['mcp.transport']).toBe('PerRequestHTTPServerTransport');
expect(mcpTrace?.data?.['network.transport']).toBe('tcp');
expect(mcpTrace?.data?.['mcp.protocol.version']).toBe('2026-07-28');
expect(mcpTrace?.data?.['mcp.client.name']).toBe('cloudflare-modern-client');
expect(mcpTrace?.data?.['mcp.client.version']).toBe('2.0.0');
expect(mcpTrace?.data?.['mcp.server.name']).toBe('cloudflare-mcp');
expect(mcpTrace?.data?.['mcp.server.version']).toBe('2.0.0');
expect(mcpTrace?.data?.['mcp.method.name']).toBe('tools/call');
expect(mcpTrace?.data?.['mcp.request.id']).toBe('modern-tool-call');
expect(mcpTrace?.data?.['mcp.tool.name']).toBe('my-tool');
expect(mcpTrace?.data?.['mcp.request.argument.message']).toBe('"modern protocol request"');
expect(mcpTrace?.data?.['mcp.tool.result.content_count']).toBe(1);
expect(mcpTrace?.data?.['mcp.tool.result.content']).toBe('Tool my-tool: modern protocol request');
});

expect(requestEvent.contexts?.trace).toEqual({
span_id: expect.any(String),
trace_id: expect.any(String),
data: expect.objectContaining({
'sentry.origin': 'auto.http.cloudflare',
'sentry.op': 'http.server',
'sentry.source': 'url',
'sentry.sample_rate': 1,
'http.request.method': 'POST',
'url.path': '/mcp',
'url.full': 'http://localhost:38787/mcp',
'url.port': '38787',
'url.scheme': 'http:',
'server.address': 'localhost',
'http.request.body.size': 120,
'user_agent.original': 'node',
'http.request.header.content_type': 'application/json',
'network.protocol.name': 'HTTP/1.1',
'mcp.server.extra': ' /|\ ^._.^ /|\ ',
'http.response.status_code': 200,
}),
op: 'http.server',
status: 'ok',
origin: 'auto.http.cloudflare',
test('keeps sending spans for legacy-compatible MCP tool calls', async ({ baseURL }) => {
const url = `${baseURL}/mcp?protocol=legacy`;
const mcpWaiter = waitForRequest(APP_NAME, eventData => {
const event = getTransaction(eventData);
return (
event?.transaction === 'tools/call my-tool' &&
event.contexts?.trace?.data?.['mcp.request.argument.message'] === '"legacy protocol request"'
);
});

expect(mcpEvent.contexts?.trace).toEqual({
trace_id: expect.any(String),
parent_span_id: requestEvent.contexts?.trace?.span_id,
span_id: expect.any(String),
op: 'mcp.server',
origin: 'auto.function.mcp_server',
status: 'ok',
data: {
'sentry.origin': 'auto.function.mcp_server',
'sentry.op': 'mcp.server',
'sentry.source': 'route',
'mcp.transport': 'WorkerTransport',
'network.transport': 'unknown',
'network.protocol.version': '2.0',
'mcp.method.name': 'tools/call',
'mcp.request.id': '1',
'mcp.tool.name': 'my-tool',
'mcp.request.argument.message': '"ʕっ•ᴥ•ʔっ"',
'mcp.tool.extra': 'ƸӜƷ',
'mcp.tool.input': '{"message":"ʕっ•ᴥ•ʔっ"}',
'mcp.tool.result.content_count': 1,
'mcp.tool.result.content_type': 'text',
'mcp.tool.result.content': 'Tool my-tool: ʕっ•ᴥ•ʔっ',
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 'legacy-tool-call',
method: 'tools/call',
params: {
name: 'my-tool',
arguments: {
message: 'legacy protocol request',
},
},
}),
});

expect(response.status).toBe(200);

const mcpEvent = requireTransaction(await mcpWaiter);
const trace = mcpEvent.contexts?.trace;

expect(trace?.op).toBe('mcp.server');
expect(trace?.status).toBe('ok');
expect(trace?.data?.['mcp.transport']).toBe('WebStandardStreamableHTTPServerTransport');
expect(trace?.data?.['mcp.method.name']).toBe('tools/call');
expect(trace?.data?.['mcp.request.id']).toBe('legacy-tool-call');
expect(trace?.data?.['mcp.tool.name']).toBe('my-tool');
expect(trace?.data?.['mcp.protocol.version']).toBeUndefined();
expect(trace?.data?.['mcp.tool.result.content']).toBe('Tool my-tool: legacy protocol request');
});
Loading
Loading