|
1 | 1 | import { expect, test } from '@playwright/test'; |
2 | 2 | import { waitForRequest } from '@sentry-internal/test-utils'; |
3 | 3 |
|
4 | | -test('sends spans for MCP tool calls', async ({ baseURL }) => { |
5 | | - const spanRequestWaiter = waitForRequest('cloudflare-mcp', event => { |
6 | | - const transaction = event.envelope[1][0][1]; |
7 | | - return typeof transaction !== 'string' && 'transaction' in transaction && transaction.transaction === 'POST /mcp'; |
8 | | - }); |
| 4 | +const APP_NAME = 'cloudflare-mcp'; |
| 5 | + |
| 6 | +function getTransaction(eventData: Awaited<ReturnType<typeof waitForRequest>>) { |
| 7 | + const event = eventData.envelope[1][0][1]; |
| 8 | + return typeof event !== 'string' && 'transaction' in event ? event : undefined; |
| 9 | +} |
| 10 | + |
| 11 | +function requireTransaction(eventData: Awaited<ReturnType<typeof waitForRequest>>) { |
| 12 | + const event = getTransaction(eventData); |
| 13 | + if (!event) { |
| 14 | + throw new Error('Expected a transaction event'); |
| 15 | + } |
| 16 | + return event; |
| 17 | +} |
| 18 | + |
| 19 | +test.describe.configure({ mode: 'serial' }); |
9 | 20 |
|
10 | | - const spanMcpWaiter = waitForRequest('cloudflare-mcp', event => { |
11 | | - const transaction = event.envelope[1][0][1]; |
| 21 | +test('sends spans for MCP 2026-07-28 tool calls', async ({ baseURL }) => { |
| 22 | + const url = `${baseURL}/mcp?protocol=modern`; |
| 23 | + const requestWaiter = waitForRequest(APP_NAME, eventData => { |
| 24 | + const event = getTransaction(eventData); |
| 25 | + return event?.transaction === 'POST /mcp' && event.contexts?.trace?.data?.['url.full'] === url; |
| 26 | + }); |
| 27 | + const mcpWaiter = waitForRequest(APP_NAME, eventData => { |
| 28 | + const event = getTransaction(eventData); |
12 | 29 | return ( |
13 | | - typeof transaction !== 'string' && |
14 | | - 'transaction' in transaction && |
15 | | - transaction.transaction === 'tools/call my-tool' |
| 30 | + event?.transaction === 'tools/call my-tool' && |
| 31 | + event.contexts?.trace?.data?.['mcp.protocol.version'] === '2026-07-28' |
16 | 32 | ); |
17 | 33 | }); |
18 | 34 |
|
19 | | - const response = await fetch(`${baseURL}/mcp`, { |
| 35 | + const response = await fetch(url, { |
20 | 36 | method: 'POST', |
21 | 37 | headers: { |
22 | 38 | 'Content-Type': 'application/json', |
23 | 39 | Accept: 'application/json, text/event-stream', |
| 40 | + 'MCP-Protocol-Version': '2026-07-28', |
| 41 | + 'Mcp-Method': 'tools/call', |
| 42 | + 'Mcp-Name': 'my-tool', |
24 | 43 | }, |
25 | 44 | body: JSON.stringify({ |
26 | 45 | jsonrpc: '2.0', |
27 | | - id: 1, |
| 46 | + id: 'modern-tool-call', |
28 | 47 | method: 'tools/call', |
29 | 48 | params: { |
| 49 | + _meta: { |
| 50 | + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', |
| 51 | + 'io.modelcontextprotocol/clientInfo': { |
| 52 | + name: 'cloudflare-modern-client', |
| 53 | + version: '2.0.0', |
| 54 | + }, |
| 55 | + 'io.modelcontextprotocol/clientCapabilities': {}, |
| 56 | + }, |
30 | 57 | name: 'my-tool', |
31 | 58 | arguments: { |
32 | | - message: 'ʕっ•ᴥ•ʔっ', |
| 59 | + message: 'modern protocol request', |
33 | 60 | }, |
34 | 61 | }, |
35 | 62 | }), |
36 | 63 | }); |
37 | 64 |
|
38 | 65 | expect(response.status).toBe(200); |
| 66 | + await expect(response.json()).resolves.toMatchObject({ |
| 67 | + jsonrpc: '2.0', |
| 68 | + id: 'modern-tool-call', |
| 69 | + result: { |
| 70 | + resultType: 'complete', |
| 71 | + content: [{ type: 'text', text: 'Tool my-tool: modern protocol request' }], |
| 72 | + }, |
| 73 | + }); |
39 | 74 |
|
40 | | - const requestData = await spanRequestWaiter; |
41 | | - const mcpData = await spanMcpWaiter; |
42 | | - |
43 | | - const requestEvent = requestData.envelope[1][0][1]; |
44 | | - const mcpEvent = mcpData.envelope[1][0][1]; |
45 | | - |
46 | | - // Check that the events have contexts |
47 | | - // this is for TypeScript type safety |
48 | | - if ( |
49 | | - typeof mcpEvent === 'string' || |
50 | | - !('contexts' in mcpEvent) || |
51 | | - typeof requestEvent === 'string' || |
52 | | - !('contexts' in requestEvent) |
53 | | - ) { |
54 | | - throw new Error("Events don't have contexts"); |
55 | | - } |
| 75 | + const requestData = await requestWaiter; |
| 76 | + const mcpData = await mcpWaiter; |
| 77 | + const requestEvent = requireTransaction(requestData); |
| 78 | + const mcpEvent = requireTransaction(mcpData); |
| 79 | + const requestTrace = requestEvent.contexts?.trace; |
| 80 | + const mcpTrace = mcpEvent.contexts?.trace; |
56 | 81 |
|
57 | | - expect(mcpEvent.contexts?.trace?.trace_id).toBe((mcpData.envelope[0].trace as any).trace_id); |
58 | | - expect(requestData.envelope[0].event_id).not.toBe(mcpData.envelope[0].event_id); |
| 82 | + expect(requestTrace?.op).toBe('http.server'); |
| 83 | + expect(requestTrace?.data?.['mcp.server.extra']).toBe(' /|\ ^._.^ /|\ '); |
| 84 | + expect(mcpTrace?.trace_id).toBe(requestTrace?.trace_id); |
| 85 | + expect(mcpTrace?.parent_span_id).toBe(requestTrace?.span_id); |
| 86 | + expect(mcpTrace?.op).toBe('mcp.server'); |
| 87 | + expect(mcpTrace?.origin).toBe('auto.function.mcp_server'); |
| 88 | + expect(mcpTrace?.status).toBe('ok'); |
| 89 | + expect(mcpTrace?.data?.['mcp.transport']).toBe('PerRequestHTTPServerTransport'); |
| 90 | + expect(mcpTrace?.data?.['network.transport']).toBe('tcp'); |
| 91 | + expect(mcpTrace?.data?.['mcp.protocol.version']).toBe('2026-07-28'); |
| 92 | + expect(mcpTrace?.data?.['mcp.client.name']).toBe('cloudflare-modern-client'); |
| 93 | + expect(mcpTrace?.data?.['mcp.client.version']).toBe('2.0.0'); |
| 94 | + expect(mcpTrace?.data?.['mcp.server.name']).toBe('cloudflare-mcp'); |
| 95 | + expect(mcpTrace?.data?.['mcp.server.version']).toBe('2.0.0'); |
| 96 | + expect(mcpTrace?.data?.['mcp.method.name']).toBe('tools/call'); |
| 97 | + expect(mcpTrace?.data?.['mcp.request.id']).toBe('modern-tool-call'); |
| 98 | + expect(mcpTrace?.data?.['mcp.tool.name']).toBe('my-tool'); |
| 99 | + expect(mcpTrace?.data?.['mcp.request.argument.message']).toBe('"modern protocol request"'); |
| 100 | + expect(mcpTrace?.data?.['mcp.tool.result.content_count']).toBe(1); |
| 101 | + expect(mcpTrace?.data?.['mcp.tool.result.content']).toBe('Tool my-tool: modern protocol request'); |
| 102 | +}); |
59 | 103 |
|
60 | | - expect(requestEvent.contexts?.trace).toEqual({ |
61 | | - span_id: expect.any(String), |
62 | | - trace_id: expect.any(String), |
63 | | - data: expect.objectContaining({ |
64 | | - 'sentry.origin': 'auto.http.cloudflare', |
65 | | - 'sentry.op': 'http.server', |
66 | | - 'sentry.source': 'url', |
67 | | - 'sentry.sample_rate': 1, |
68 | | - 'http.request.method': 'POST', |
69 | | - 'url.path': '/mcp', |
70 | | - 'url.full': 'http://localhost:38787/mcp', |
71 | | - 'url.port': '38787', |
72 | | - 'url.scheme': 'http:', |
73 | | - 'server.address': 'localhost', |
74 | | - 'http.request.body.size': 120, |
75 | | - 'user_agent.original': 'node', |
76 | | - 'http.request.header.content_type': 'application/json', |
77 | | - 'network.protocol.name': 'HTTP/1.1', |
78 | | - 'mcp.server.extra': ' /|\ ^._.^ /|\ ', |
79 | | - 'http.response.status_code': 200, |
80 | | - }), |
81 | | - op: 'http.server', |
82 | | - status: 'ok', |
83 | | - origin: 'auto.http.cloudflare', |
| 104 | +test('keeps sending spans for legacy-compatible MCP tool calls', async ({ baseURL }) => { |
| 105 | + const url = `${baseURL}/mcp?protocol=legacy`; |
| 106 | + const mcpWaiter = waitForRequest(APP_NAME, eventData => { |
| 107 | + const event = getTransaction(eventData); |
| 108 | + return ( |
| 109 | + event?.transaction === 'tools/call my-tool' && |
| 110 | + event.contexts?.trace?.data?.['mcp.request.argument.message'] === '"legacy protocol request"' |
| 111 | + ); |
84 | 112 | }); |
85 | 113 |
|
86 | | - expect(mcpEvent.contexts?.trace).toEqual({ |
87 | | - trace_id: expect.any(String), |
88 | | - parent_span_id: requestEvent.contexts?.trace?.span_id, |
89 | | - span_id: expect.any(String), |
90 | | - op: 'mcp.server', |
91 | | - origin: 'auto.function.mcp_server', |
92 | | - status: 'ok', |
93 | | - data: { |
94 | | - 'sentry.origin': 'auto.function.mcp_server', |
95 | | - 'sentry.op': 'mcp.server', |
96 | | - 'sentry.source': 'route', |
97 | | - 'mcp.transport': 'WorkerTransport', |
98 | | - 'network.transport': 'unknown', |
99 | | - 'network.protocol.version': '2.0', |
100 | | - 'mcp.method.name': 'tools/call', |
101 | | - 'mcp.request.id': '1', |
102 | | - 'mcp.tool.name': 'my-tool', |
103 | | - 'mcp.request.argument.message': '"ʕっ•ᴥ•ʔっ"', |
104 | | - 'mcp.tool.extra': 'ƸӜƷ', |
105 | | - 'mcp.tool.input': '{"message":"ʕっ•ᴥ•ʔっ"}', |
106 | | - 'mcp.tool.result.content_count': 1, |
107 | | - 'mcp.tool.result.content_type': 'text', |
108 | | - 'mcp.tool.result.content': 'Tool my-tool: ʕっ•ᴥ•ʔっ', |
| 114 | + const response = await fetch(url, { |
| 115 | + method: 'POST', |
| 116 | + headers: { |
| 117 | + 'Content-Type': 'application/json', |
| 118 | + Accept: 'application/json, text/event-stream', |
109 | 119 | }, |
| 120 | + body: JSON.stringify({ |
| 121 | + jsonrpc: '2.0', |
| 122 | + id: 'legacy-tool-call', |
| 123 | + method: 'tools/call', |
| 124 | + params: { |
| 125 | + name: 'my-tool', |
| 126 | + arguments: { |
| 127 | + message: 'legacy protocol request', |
| 128 | + }, |
| 129 | + }, |
| 130 | + }), |
110 | 131 | }); |
| 132 | + |
| 133 | + expect(response.status).toBe(200); |
| 134 | + |
| 135 | + const mcpEvent = requireTransaction(await mcpWaiter); |
| 136 | + const trace = mcpEvent.contexts?.trace; |
| 137 | + |
| 138 | + expect(trace?.op).toBe('mcp.server'); |
| 139 | + expect(trace?.status).toBe('ok'); |
| 140 | + expect(trace?.data?.['mcp.transport']).toBe('WebStandardStreamableHTTPServerTransport'); |
| 141 | + expect(trace?.data?.['mcp.method.name']).toBe('tools/call'); |
| 142 | + expect(trace?.data?.['mcp.request.id']).toBe('legacy-tool-call'); |
| 143 | + expect(trace?.data?.['mcp.tool.name']).toBe('my-tool'); |
| 144 | + expect(trace?.data?.['mcp.protocol.version']).toBeUndefined(); |
| 145 | + expect(trace?.data?.['mcp.tool.result.content']).toBe('Tool my-tool: legacy protocol request'); |
111 | 146 | }); |
0 commit comments