Merged
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 numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
137 changes: 137 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { wrapMcpServerWithSentry } from '@sentry/node';

// Helper to check if request is an initialize request (compatible with all MCP SDK versions)
function isInitializeRequest(body: unknown): boolean {
return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize';
}

const mcpRouter = express.Router();

const server = wrapMcpServerWithSentry(
Expand DownExpand Up@@ -61,4 +68,134 @@ mcpRouter.post('/messages', async (req, res) => {
}
});

// =============================================================================
// Streamable HTTP Transport Endpoints
// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport
// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation
// See: https://github.com/getsentry/sentry-mcp/issues/767
// =============================================================================

// Create a separate wrapped server for streamable HTTP (to test independent of SSE)
const streamableServer = wrapMcpServerWithSentry(
new McpServer({
name: 'Echo-Streamable',
version: '1.0.0',
}),
);

// Register the same handlers on the streamable server
streamableServer.resource(
'echo',
new ResourceTemplate('echo://{message}', { list: undefined }),
async (uri, { message }) => ({
contents: [
{
uri: uri.href,
text: `Resource echo: ${message}`,
},
],
}),
);

streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
return {
content: [{ type: 'text', text: `Tool echo: ${message}` }],
};
});

streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please process this message: ${message}`,
},
},
],
}));

// Map to store streamable transports by session ID
const streamableTransports: Record<string, StreamableHTTPServerTransport> = {};

// POST endpoint for streamable HTTP (handles both initialization and subsequent requests)
mcpRouter.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

try {
let transport: StreamableHTTPServerTransport;

if (sessionId && streamableTransports[sessionId]) {
// Reuse existing transport for session
transport = streamableTransports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
// Store transport when session is initialized
streamableTransports[sid] = transport;
},
});

// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && streamableTransports[sid]) {
delete streamableTransports[sid];
}
};

// Connect to server before handling request
await streamableServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}

// Handle request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling streamable HTTP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});

// GET endpoint for SSE streams (server-initiated messages)
mcpRouter.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

// DELETE endpoint for session termination
mcpRouter.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

export { mcpRouter };
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

test('Should record transactions for mcp handlers', async ({ baseURL }) => {
const transport = new SSEClientTransport(new URL(`${baseURL}/sse`));
Expand DownExpand Up@@ -120,3 +121,135 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => {
// TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction
});
});

/**
* Tests for StreamableHTTPServerTransport (wrapper transport pattern)
*
* StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters.
* This causes different `this` values in onmessage vs send, which was breaking span correlation.
*
* The fix uses sessionId as the correlation key instead of transport object reference.
* This test verifies that spans are correctly recorded when using the wrapper transport.
*
* @see https://github.com/getsentry/sentry-mcp/issues/767
*/
test('Should record transactions for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => {
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));

const client = new Client({
name: 'test-client-streamable',
version: '1.0.0',
});

const initializeTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
return (
transactionEvent.transaction === 'initialize' &&
transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable'
);
});

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-streamable');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable');
// Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment)
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
});

await test.step('tool handler (tests wrapper transport correlation)', async () => {
// This is the critical test - without the sessionId fix, the span would not be completed
// because onmessage and send see different transport instances (wrapper vs inner)
const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return transactionEvent.transaction === 'tools/call echo' && transport?.includes('StreamableHTTPServerTransport');
});

const toolResult = await client.callTool({
name: 'echo',
arguments: {
message: 'wrapper-transport-test',
},
});

expect(toolResult).toMatchObject({
content: [
{
text: 'Tool echo: wrapper-transport-test',
type: 'text',
},
],
});

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
// This attribute proves the span was completed with results (sessionId correlation worked)
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
});

await test.step('resource handler', async () => {
const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'resources/read echo://streamable-test' &&
transport?.includes('StreamableHTTPServerTransport')
);
});

const resourceResult = await client.readResource({
uri: 'echo://streamable-test',
});

expect(resourceResult).toMatchObject({
contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }],
});

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
});

await test.step('prompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'prompts/get echo' && transport?.includes('StreamableHTTPServerTransport')
);
});

const promptResult = await client.getPrompt({
name: 'echo',
arguments: {
message: 'streamable-prompt',
},
});

expect(promptResult).toMatchObject({
messages: [
{
content: {
text: 'Please process this message: streamable-prompt',
type: 'text',
},
role: 'user',
},
],
});

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
});

// Clean up - close the client connection
await client.close();
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
137 changes: 137 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { wrapMcpServerWithSentry } from '@sentry/node';

// Helper to check if request is an initialize request (compatible with all MCP SDK versions)
function isInitializeRequest(body: unknown): boolean {
return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize';
}

const mcpRouter = express.Router();

const server = wrapMcpServerWithSentry(
Expand DownExpand Up@@ -61,4 +68,134 @@ mcpRouter.post('/messages', async (req, res) => {
}
});

// =============================================================================
// Streamable HTTP Transport Endpoints
// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport
// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation
// See: https://github.com/getsentry/sentry-mcp/issues/767
// =============================================================================

// Create a separate wrapped server for streamable HTTP (to test independent of SSE)
const streamableServer = wrapMcpServerWithSentry(
new McpServer({
name: 'Echo-Streamable',
version: '1.0.0',
}),
);

// Register the same handlers on the streamable server
streamableServer.resource(
'echo',
new ResourceTemplate('echo://{message}', { list: undefined }),
async (uri, { message }) => ({
contents: [
{
uri: uri.href,
text: `Resource echo: ${message}`,
},
],
}),
);

streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
return {
content: [{ type: 'text', text: `Tool echo: ${message}` }],
};
});

streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please process this message: ${message}`,
},
},
],
}));

// Map to store streamable transports by session ID
const streamableTransports: Record<string, StreamableHTTPServerTransport> = {};

// POST endpoint for streamable HTTP (handles both initialization and subsequent requests)
mcpRouter.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

try {
let transport: StreamableHTTPServerTransport;

if (sessionId && streamableTransports[sessionId]) {
// Reuse existing transport for session
transport = streamableTransports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
// Store transport when session is initialized
streamableTransports[sid] = transport;
},
});

// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && streamableTransports[sid]) {
delete streamableTransports[sid];
}
};

// Connect to server before handling request
await streamableServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}

// Handle request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling streamable HTTP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});

// GET endpoint for SSE streams (server-initiated messages)
mcpRouter.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

// DELETE endpoint for session termination
mcpRouter.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

export { mcpRouter };
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

test('Should record transactions for mcp handlers', async ({ baseURL }) => {
const transport = new SSEClientTransport(new URL(`${baseURL}/sse`));
Expand DownExpand Up@@ -120,3 +121,135 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => {
// TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction
});
});

/**
* Tests for StreamableHTTPServerTransport (wrapper transport pattern)
*
* StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters.
* This causes different `this` values in onmessage vs send, which was breaking span correlation.
*
* The fix uses sessionId as the correlation key instead of transport object reference.
* This test verifies that spans are correctly recorded when using the wrapper transport.
*
* @see https://github.com/getsentry/sentry-mcp/issues/767
*/
test('Should record transactions for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => {
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));

const client = new Client({
name: 'test-client-streamable',
version: '1.0.0',
});

const initializeTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
return (
transactionEvent.transaction === 'initialize' &&
transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable'
);
});

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-streamable');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable');
// Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment)
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
});

await test.step('tool handler (tests wrapper transport correlation)', async () => {
// This is the critical test - without the sessionId fix, the span would not be completed
// because onmessage and send see different transport instances (wrapper vs inner)
const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return transactionEvent.transaction === 'tools/call echo' && transport?.includes('StreamableHTTPServerTransport');
});

const toolResult = await client.callTool({
name: 'echo',
arguments: {
message: 'wrapper-transport-test',
},
});

expect(toolResult).toMatchObject({
content: [
{
text: 'Tool echo: wrapper-transport-test',
type: 'text',
},
],
});

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
// This attribute proves the span was completed with results (sessionId correlation worked)
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
});

await test.step('resource handler', async () => {
const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'resources/read echo://streamable-test' &&
transport?.includes('StreamableHTTPServerTransport')
);
});

const resourceResult = await client.readResource({
uri: 'echo://streamable-test',
});

expect(resourceResult).toMatchObject({
contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }],
});

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
});

await test.step('prompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'prompts/get echo' && transport?.includes('StreamableHTTPServerTransport')
);
});

const promptResult = await client.getPrompt({
name: 'echo',
arguments: {
message: 'streamable-prompt',
},
});

expect(promptResult).toMatchObject({
messages: [
{
content: {
text: 'Please process this message: streamable-prompt',
type: 'text',
},
role: 'user',
},
],
});

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
});

// Clean up - close the client connection
await client.close();
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
137 changes: 137 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { wrapMcpServerWithSentry } from '@sentry/node';

// Helper to check if request is an initialize request (compatible with all MCP SDK versions)
function isInitializeRequest(body: unknown): boolean {
return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize';
}

const mcpRouter = express.Router();

const server = wrapMcpServerWithSentry(
Expand DownExpand Up@@ -61,4 +68,134 @@ mcpRouter.post('/messages', async (req, res) => {
}
});

// =============================================================================
// Streamable HTTP Transport Endpoints
// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport
// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation
// See: https://github.com/getsentry/sentry-mcp/issues/767
// =============================================================================

// Create a separate wrapped server for streamable HTTP (to test independent of SSE)
const streamableServer = wrapMcpServerWithSentry(
new McpServer({
name: 'Echo-Streamable',
version: '1.0.0',
}),
);

// Register the same handlers on the streamable server
streamableServer.resource(
'echo',
new ResourceTemplate('echo://{message}', { list: undefined }),
async (uri, { message }) => ({
contents: [
{
uri: uri.href,
text: `Resource echo: ${message}`,
},
],
}),
);

streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
return {
content: [{ type: 'text', text: `Tool echo: ${message}` }],
};
});

streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please process this message: ${message}`,
},
},
],
}));

// Map to store streamable transports by session ID
const streamableTransports: Record<string, StreamableHTTPServerTransport> = {};

// POST endpoint for streamable HTTP (handles both initialization and subsequent requests)
mcpRouter.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

try {
let transport: StreamableHTTPServerTransport;

if (sessionId && streamableTransports[sessionId]) {
// Reuse existing transport for session
transport = streamableTransports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
// Store transport when session is initialized
streamableTransports[sid] = transport;
},
});

// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && streamableTransports[sid]) {
delete streamableTransports[sid];
}
};

// Connect to server before handling request
await streamableServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}

// Handle request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling streamable HTTP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});

// GET endpoint for SSE streams (server-initiated messages)
mcpRouter.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

// DELETE endpoint for session termination
mcpRouter.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

export { mcpRouter };
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

test('Should record transactions for mcp handlers', async ({ baseURL }) => {
const transport = new SSEClientTransport(new URL(`${baseURL}/sse`));
Expand DownExpand Up@@ -120,3 +121,135 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => {
// TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction
});
});

/**
* Tests for StreamableHTTPServerTransport (wrapper transport pattern)
*
* StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters.
* This causes different `this` values in onmessage vs send, which was breaking span correlation.
*
* The fix uses sessionId as the correlation key instead of transport object reference.
* This test verifies that spans are correctly recorded when using the wrapper transport.
*
* @see https://github.com/getsentry/sentry-mcp/issues/767
*/
test('Should record transactions for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => {
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));

const client = new Client({
name: 'test-client-streamable',
version: '1.0.0',
});

const initializeTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
return (
transactionEvent.transaction === 'initialize' &&
transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable'
);
});

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-streamable');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable');
// Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment)
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
});

await test.step('tool handler (tests wrapper transport correlation)', async () => {
// This is the critical test - without the sessionId fix, the span would not be completed
// because onmessage and send see different transport instances (wrapper vs inner)
const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return transactionEvent.transaction === 'tools/call echo' && transport?.includes('StreamableHTTPServerTransport');
});

const toolResult = await client.callTool({
name: 'echo',
arguments: {
message: 'wrapper-transport-test',
},
});

expect(toolResult).toMatchObject({
content: [
{
text: 'Tool echo: wrapper-transport-test',
type: 'text',
},
],
});

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
// This attribute proves the span was completed with results (sessionId correlation worked)
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
});

await test.step('resource handler', async () => {
const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'resources/read echo://streamable-test' &&
transport?.includes('StreamableHTTPServerTransport')
);
});

const resourceResult = await client.readResource({
uri: 'echo://streamable-test',
});

expect(resourceResult).toMatchObject({
contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }],
});

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
});

await test.step('prompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'prompts/get echo' && transport?.includes('StreamableHTTPServerTransport')
);
});

const promptResult = await client.getPrompt({
name: 'echo',
arguments: {
message: 'streamable-prompt',
},
});

expect(promptResult).toMatchObject({
messages: [
{
content: {
text: 'Please process this message: streamable-prompt',
type: 'text',
},
role: 'user',
},
],
});

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
});

// Clean up - close the client connection
await client.close();
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
137 changes: 137 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { wrapMcpServerWithSentry } from '@sentry/node';

// Helper to check if request is an initialize request (compatible with all MCP SDK versions)
function isInitializeRequest(body: unknown): boolean {
return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize';
}

const mcpRouter = express.Router();

const server = wrapMcpServerWithSentry(
Expand DownExpand Up@@ -61,4 +68,134 @@ mcpRouter.post('/messages', async (req, res) => {
}
});

// =============================================================================
// Streamable HTTP Transport Endpoints
// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport
// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation
// See: https://github.com/getsentry/sentry-mcp/issues/767
// =============================================================================

// Create a separate wrapped server for streamable HTTP (to test independent of SSE)
const streamableServer = wrapMcpServerWithSentry(
new McpServer({
name: 'Echo-Streamable',
version: '1.0.0',
}),
);

// Register the same handlers on the streamable server
streamableServer.resource(
'echo',
new ResourceTemplate('echo://{message}', { list: undefined }),
async (uri, { message }) => ({
contents: [
{
uri: uri.href,
text: `Resource echo: ${message}`,
},
],
}),
);

streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
return {
content: [{ type: 'text', text: `Tool echo: ${message}` }],
};
});

streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please process this message: ${message}`,
},
},
],
}));

// Map to store streamable transports by session ID
const streamableTransports: Record<string, StreamableHTTPServerTransport> = {};

// POST endpoint for streamable HTTP (handles both initialization and subsequent requests)
mcpRouter.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

try {
let transport: StreamableHTTPServerTransport;

if (sessionId && streamableTransports[sessionId]) {
// Reuse existing transport for session
transport = streamableTransports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
// Store transport when session is initialized
streamableTransports[sid] = transport;
},
});

// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && streamableTransports[sid]) {
delete streamableTransports[sid];
}
};

// Connect to server before handling request
await streamableServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}

// Handle request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling streamable HTTP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});

// GET endpoint for SSE streams (server-initiated messages)
mcpRouter.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

// DELETE endpoint for session termination
mcpRouter.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

export { mcpRouter };
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

test('Should record transactions for mcp handlers', async ({ baseURL }) => {
const transport = new SSEClientTransport(new URL(`${baseURL}/sse`));
Expand DownExpand Up@@ -120,3 +121,135 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => {
// TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction
});
});

/**
* Tests for StreamableHTTPServerTransport (wrapper transport pattern)
*
* StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters.
* This causes different `this` values in onmessage vs send, which was breaking span correlation.
*
* The fix uses sessionId as the correlation key instead of transport object reference.
* This test verifies that spans are correctly recorded when using the wrapper transport.
*
* @see https://github.com/getsentry/sentry-mcp/issues/767
*/
test('Should record transactions for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => {
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));

const client = new Client({
name: 'test-client-streamable',
version: '1.0.0',
});

const initializeTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
return (
transactionEvent.transaction === 'initialize' &&
transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable'
);
});

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-streamable');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable');
// Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment)
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
});

await test.step('tool handler (tests wrapper transport correlation)', async () => {
// This is the critical test - without the sessionId fix, the span would not be completed
// because onmessage and send see different transport instances (wrapper vs inner)
const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return transactionEvent.transaction === 'tools/call echo' && transport?.includes('StreamableHTTPServerTransport');
});

const toolResult = await client.callTool({
name: 'echo',
arguments: {
message: 'wrapper-transport-test',
},
});

expect(toolResult).toMatchObject({
content: [
{
text: 'Tool echo: wrapper-transport-test',
type: 'text',
},
],
});

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
// This attribute proves the span was completed with results (sessionId correlation worked)
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
});

await test.step('resource handler', async () => {
const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'resources/read echo://streamable-test' &&
transport?.includes('StreamableHTTPServerTransport')
);
});

const resourceResult = await client.readResource({
uri: 'echo://streamable-test',
});

expect(resourceResult).toMatchObject({
contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }],
});

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
});

await test.step('prompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'prompts/get echo' && transport?.includes('StreamableHTTPServerTransport')
);
});

const promptResult = await client.getPrompt({
name: 'echo',
arguments: {
message: 'streamable-prompt',
},
});

expect(promptResult).toMatchObject({
messages: [
{
content: {
text: 'Please process this message: streamable-prompt',
type: 'text',
},
role: 'user',
},
],
});

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
});

// Clean up - close the client connection
await client.close();
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
137 changes: 137 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { wrapMcpServerWithSentry } from '@sentry/node';

// Helper to check if request is an initialize request (compatible with all MCP SDK versions)
function isInitializeRequest(body: unknown): boolean {
return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize';
}

const mcpRouter = express.Router();

const server = wrapMcpServerWithSentry(
Expand DownExpand Up@@ -61,4 +68,134 @@ mcpRouter.post('/messages', async (req, res) => {
}
});

// =============================================================================
// Streamable HTTP Transport Endpoints
// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport
// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation
// See: https://github.com/getsentry/sentry-mcp/issues/767
// =============================================================================

// Create a separate wrapped server for streamable HTTP (to test independent of SSE)
const streamableServer = wrapMcpServerWithSentry(
new McpServer({
name: 'Echo-Streamable',
version: '1.0.0',
}),
);

// Register the same handlers on the streamable server
streamableServer.resource(
'echo',
new ResourceTemplate('echo://{message}', { list: undefined }),
async (uri, { message }) => ({
contents: [
{
uri: uri.href,
text: `Resource echo: ${message}`,
},
],
}),
);

streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
return {
content: [{ type: 'text', text: `Tool echo: ${message}` }],
};
});

streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please process this message: ${message}`,
},
},
],
}));

// Map to store streamable transports by session ID
const streamableTransports: Record<string, StreamableHTTPServerTransport> = {};

// POST endpoint for streamable HTTP (handles both initialization and subsequent requests)
mcpRouter.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

try {
let transport: StreamableHTTPServerTransport;

if (sessionId && streamableTransports[sessionId]) {
// Reuse existing transport for session
transport = streamableTransports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
// Store transport when session is initialized
streamableTransports[sid] = transport;
},
});

// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && streamableTransports[sid]) {
delete streamableTransports[sid];
}
};

// Connect to server before handling request
await streamableServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}

// Handle request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling streamable HTTP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});

// GET endpoint for SSE streams (server-initiated messages)
mcpRouter.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

// DELETE endpoint for session termination
mcpRouter.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

export { mcpRouter };
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

test('Should record transactions for mcp handlers', async ({ baseURL }) => {
const transport = new SSEClientTransport(new URL(`${baseURL}/sse`));
Expand DownExpand Up@@ -120,3 +121,135 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => {
// TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction
});
});

/**
* Tests for StreamableHTTPServerTransport (wrapper transport pattern)
*
* StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters.
* This causes different `this` values in onmessage vs send, which was breaking span correlation.
*
* The fix uses sessionId as the correlation key instead of transport object reference.
* This test verifies that spans are correctly recorded when using the wrapper transport.
*
* @see https://github.com/getsentry/sentry-mcp/issues/767
*/
test('Should record transactions for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => {
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));

const client = new Client({
name: 'test-client-streamable',
version: '1.0.0',
});

const initializeTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
return (
transactionEvent.transaction === 'initialize' &&
transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable'
);
});

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-streamable');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable');
// Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment)
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
});

await test.step('tool handler (tests wrapper transport correlation)', async () => {
// This is the critical test - without the sessionId fix, the span would not be completed
// because onmessage and send see different transport instances (wrapper vs inner)
const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return transactionEvent.transaction === 'tools/call echo' && transport?.includes('StreamableHTTPServerTransport');
});

const toolResult = await client.callTool({
name: 'echo',
arguments: {
message: 'wrapper-transport-test',
},
});

expect(toolResult).toMatchObject({
content: [
{
text: 'Tool echo: wrapper-transport-test',
type: 'text',
},
],
});

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
// This attribute proves the span was completed with results (sessionId correlation worked)
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
});

await test.step('resource handler', async () => {
const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'resources/read echo://streamable-test' &&
transport?.includes('StreamableHTTPServerTransport')
);
});

const resourceResult = await client.readResource({
uri: 'echo://streamable-test',
});

expect(resourceResult).toMatchObject({
contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }],
});

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
});

await test.step('prompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'prompts/get echo' && transport?.includes('StreamableHTTPServerTransport')
);
});

const promptResult = await client.getPrompt({
name: 'echo',
arguments: {
message: 'streamable-prompt',
},
});

expect(promptResult).toMatchObject({
messages: [
{
content: {
text: 'Please process this message: streamable-prompt',
type: 'text',
},
role: 'user',
},
],
});

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
});

// Clean up - close the client connection
await client.close();
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
137 changes: 137 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { wrapMcpServerWithSentry } from '@sentry/node';

// Helper to check if request is an initialize request (compatible with all MCP SDK versions)
function isInitializeRequest(body: unknown): boolean {
return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize';
}

const mcpRouter = express.Router();

const server = wrapMcpServerWithSentry(
Expand DownExpand Up@@ -61,4 +68,134 @@ mcpRouter.post('/messages', async (req, res) => {
}
});

// =============================================================================
// Streamable HTTP Transport Endpoints
// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport
// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation
// See: https://github.com/getsentry/sentry-mcp/issues/767
// =============================================================================

// Create a separate wrapped server for streamable HTTP (to test independent of SSE)
const streamableServer = wrapMcpServerWithSentry(
new McpServer({
name: 'Echo-Streamable',
version: '1.0.0',
}),
);

// Register the same handlers on the streamable server
streamableServer.resource(
'echo',
new ResourceTemplate('echo://{message}', { list: undefined }),
async (uri, { message }) => ({
contents: [
{
uri: uri.href,
text: `Resource echo: ${message}`,
},
],
}),
);

streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
return {
content: [{ type: 'text', text: `Tool echo: ${message}` }],
};
});

streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please process this message: ${message}`,
},
},
],
}));

// Map to store streamable transports by session ID
const streamableTransports: Record<string, StreamableHTTPServerTransport> = {};

// POST endpoint for streamable HTTP (handles both initialization and subsequent requests)
mcpRouter.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

try {
let transport: StreamableHTTPServerTransport;

if (sessionId && streamableTransports[sessionId]) {
// Reuse existing transport for session
transport = streamableTransports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
// Store transport when session is initialized
streamableTransports[sid] = transport;
},
});

// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && streamableTransports[sid]) {
delete streamableTransports[sid];
}
};

// Connect to server before handling request
await streamableServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}

// Handle request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling streamable HTTP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});

// GET endpoint for SSE streams (server-initiated messages)
mcpRouter.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

// DELETE endpoint for session termination
mcpRouter.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

export { mcpRouter };
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

test('Should record transactions for mcp handlers', async ({ baseURL }) => {
const transport = new SSEClientTransport(new URL(`${baseURL}/sse`));
Expand DownExpand Up@@ -120,3 +121,135 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => {
// TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction
});
});

/**
* Tests for StreamableHTTPServerTransport (wrapper transport pattern)
*
* StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters.
* This causes different `this` values in onmessage vs send, which was breaking span correlation.
*
* The fix uses sessionId as the correlation key instead of transport object reference.
* This test verifies that spans are correctly recorded when using the wrapper transport.
*
* @see https://github.com/getsentry/sentry-mcp/issues/767
*/
test('Should record transactions for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => {
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));

const client = new Client({
name: 'test-client-streamable',
version: '1.0.0',
});

const initializeTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
return (
transactionEvent.transaction === 'initialize' &&
transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable'
);
});

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-streamable');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable');
// Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment)
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
});

await test.step('tool handler (tests wrapper transport correlation)', async () => {
// This is the critical test - without the sessionId fix, the span would not be completed
// because onmessage and send see different transport instances (wrapper vs inner)
const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return transactionEvent.transaction === 'tools/call echo' && transport?.includes('StreamableHTTPServerTransport');
});

const toolResult = await client.callTool({
name: 'echo',
arguments: {
message: 'wrapper-transport-test',
},
});

expect(toolResult).toMatchObject({
content: [
{
text: 'Tool echo: wrapper-transport-test',
type: 'text',
},
],
});

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
// This attribute proves the span was completed with results (sessionId correlation worked)
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
});

await test.step('resource handler', async () => {
const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'resources/read echo://streamable-test' &&
transport?.includes('StreamableHTTPServerTransport')
);
});

const resourceResult = await client.readResource({
uri: 'echo://streamable-test',
});

expect(resourceResult).toMatchObject({
contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }],
});

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
});

await test.step('prompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'prompts/get echo' && transport?.includes('StreamableHTTPServerTransport')
);
});

const promptResult = await client.getPrompt({
name: 'echo',
arguments: {
message: 'streamable-prompt',
},
});

expect(promptResult).toMatchObject({
messages: [
{
content: {
text: 'Please process this message: streamable-prompt',
type: 'text',
},
role: 'user',
},
],
});

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
});

// Clean up - close the client connection
await client.close();
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
137 changes: 137 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { wrapMcpServerWithSentry } from '@sentry/node';

// Helper to check if request is an initialize request (compatible with all MCP SDK versions)
function isInitializeRequest(body: unknown): boolean {
return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize';
}

const mcpRouter = express.Router();

const server = wrapMcpServerWithSentry(
Expand DownExpand Up@@ -61,4 +68,134 @@ mcpRouter.post('/messages', async (req, res) => {
}
});

// =============================================================================
// Streamable HTTP Transport Endpoints
// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport
// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation
// See: https://github.com/getsentry/sentry-mcp/issues/767
// =============================================================================

// Create a separate wrapped server for streamable HTTP (to test independent of SSE)
const streamableServer = wrapMcpServerWithSentry(
new McpServer({
name: 'Echo-Streamable',
version: '1.0.0',
}),
);

// Register the same handlers on the streamable server
streamableServer.resource(
'echo',
new ResourceTemplate('echo://{message}', { list: undefined }),
async (uri, { message }) => ({
contents: [
{
uri: uri.href,
text: `Resource echo: ${message}`,
},
],
}),
);

streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
return {
content: [{ type: 'text', text: `Tool echo: ${message}` }],
};
});

streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please process this message: ${message}`,
},
},
],
}));

// Map to store streamable transports by session ID
const streamableTransports: Record<string, StreamableHTTPServerTransport> = {};

// POST endpoint for streamable HTTP (handles both initialization and subsequent requests)
mcpRouter.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

try {
let transport: StreamableHTTPServerTransport;

if (sessionId && streamableTransports[sessionId]) {
// Reuse existing transport for session
transport = streamableTransports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
// Store transport when session is initialized
streamableTransports[sid] = transport;
},
});

// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && streamableTransports[sid]) {
delete streamableTransports[sid];
}
};

// Connect to server before handling request
await streamableServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}

// Handle request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling streamable HTTP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});

// GET endpoint for SSE streams (server-initiated messages)
mcpRouter.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

// DELETE endpoint for session termination
mcpRouter.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

export { mcpRouter };
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

test('Should record transactions for mcp handlers', async ({ baseURL }) => {
const transport = new SSEClientTransport(new URL(`${baseURL}/sse`));
Expand DownExpand Up@@ -120,3 +121,135 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => {
// TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction
});
});

/**
* Tests for StreamableHTTPServerTransport (wrapper transport pattern)
*
* StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters.
* This causes different `this` values in onmessage vs send, which was breaking span correlation.
*
* The fix uses sessionId as the correlation key instead of transport object reference.
* This test verifies that spans are correctly recorded when using the wrapper transport.
*
* @see https://github.com/getsentry/sentry-mcp/issues/767
*/
test('Should record transactions for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => {
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));

const client = new Client({
name: 'test-client-streamable',
version: '1.0.0',
});

const initializeTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
return (
transactionEvent.transaction === 'initialize' &&
transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable'
);
});

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-streamable');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable');
// Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment)
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
});

await test.step('tool handler (tests wrapper transport correlation)', async () => {
// This is the critical test - without the sessionId fix, the span would not be completed
// because onmessage and send see different transport instances (wrapper vs inner)
const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return transactionEvent.transaction === 'tools/call echo' && transport?.includes('StreamableHTTPServerTransport');
});

const toolResult = await client.callTool({
name: 'echo',
arguments: {
message: 'wrapper-transport-test',
},
});

expect(toolResult).toMatchObject({
content: [
{
text: 'Tool echo: wrapper-transport-test',
type: 'text',
},
],
});

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
// This attribute proves the span was completed with results (sessionId correlation worked)
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
});

await test.step('resource handler', async () => {
const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'resources/read echo://streamable-test' &&
transport?.includes('StreamableHTTPServerTransport')
);
});

const resourceResult = await client.readResource({
uri: 'echo://streamable-test',
});

expect(resourceResult).toMatchObject({
contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }],
});

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
});

await test.step('prompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'prompts/get echo' && transport?.includes('StreamableHTTPServerTransport')
);
});

const promptResult = await client.getPrompt({
name: 'echo',
arguments: {
message: 'streamable-prompt',
},
});

expect(promptResult).toMatchObject({
messages: [
{
content: {
text: 'Please process this message: streamable-prompt',
type: 'text',
},
role: 'user',
},
],
});

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
});

// Clean up - close the client connection
await client.close();
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
137 changes: 137 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { wrapMcpServerWithSentry } from '@sentry/node';

// Helper to check if request is an initialize request (compatible with all MCP SDK versions)
function isInitializeRequest(body: unknown): boolean {
return typeof body === 'object' && body !== null && (body as { method?: string }).method === 'initialize';
}

const mcpRouter = express.Router();

const server = wrapMcpServerWithSentry(
Expand DownExpand Up@@ -61,4 +68,134 @@ mcpRouter.post('/messages', async (req, res) => {
}
});

// =============================================================================
// Streamable HTTP Transport Endpoints
// This uses StreamableHTTPServerTransport which wraps WebStandardStreamableHTTPServerTransport
// and exercises the wrapper transport pattern that was fixed in the sessionId-based correlation
// See: https://github.com/getsentry/sentry-mcp/issues/767
// =============================================================================

// Create a separate wrapped server for streamable HTTP (to test independent of SSE)
const streamableServer = wrapMcpServerWithSentry(
new McpServer({
name: 'Echo-Streamable',
version: '1.0.0',
}),
);

// Register the same handlers on the streamable server
streamableServer.resource(
'echo',
new ResourceTemplate('echo://{message}', { list: undefined }),
async (uri, { message }) => ({
contents: [
{
uri: uri.href,
text: `Resource echo: ${message}`,
},
],
}),
);

streamableServer.tool('echo', { message: z.string() }, async ({ message }) => {
return {
content: [{ type: 'text', text: `Tool echo: ${message}` }],
};
});

streamableServer.prompt('echo', { message: z.string() }, ({ message }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please process this message: ${message}`,
},
},
],
}));

// Map to store streamable transports by session ID
const streamableTransports: Record<string, StreamableHTTPServerTransport> = {};

// POST endpoint for streamable HTTP (handles both initialization and subsequent requests)
mcpRouter.post('/mcp', express.json(), async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;

try {
let transport: StreamableHTTPServerTransport;

if (sessionId && streamableTransports[sessionId]) {
// Reuse existing transport for session
transport = streamableTransports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
// Store transport when session is initialized
streamableTransports[sid] = transport;
},
});

// Clean up on close
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && streamableTransports[sid]) {
delete streamableTransports[sid];
}
};

// Connect to server before handling request
await streamableServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
// Invalid request
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}

// Handle request with existing transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling streamable HTTP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});

// GET endpoint for SSE streams (server-initiated messages)
mcpRouter.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

// DELETE endpoint for session termination
mcpRouter.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !streamableTransports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}

const transport = streamableTransports[sessionId];
await transport.handleRequest(req, res);
});

export { mcpRouter };
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

test('Should record transactions for mcp handlers', async ({ baseURL }) => {
const transport = new SSEClientTransport(new URL(`${baseURL}/sse`));
Expand DownExpand Up@@ -120,3 +121,135 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => {
// TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction
});
});

/**
* Tests for StreamableHTTPServerTransport (wrapper transport pattern)
*
* StreamableHTTPServerTransport wraps WebStandardStreamableHTTPServerTransport via getters/setters.
* This causes different `this` values in onmessage vs send, which was breaking span correlation.
*
* The fix uses sessionId as the correlation key instead of transport object reference.
* This test verifies that spans are correctly recorded when using the wrapper transport.
*
* @see https://github.com/getsentry/sentry-mcp/issues/767
*/
test('Should record transactions for streamable HTTP transport (wrapper transport pattern)', async ({ baseURL }) => {
const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`));

const client = new Client({
name: 'test-client-streamable',
version: '1.0.0',
});

const initializeTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
return (
transactionEvent.transaction === 'initialize' &&
transactionEvent.contexts?.trace?.data?.['mcp.server.name'] === 'Echo-Streamable'
);
});

await client.connect(transport);

await test.step('initialize handshake', async () => {
const initializeTransaction = await initializeTransactionPromise;
expect(initializeTransaction).toBeDefined();
expect(initializeTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('initialize');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.client.name']).toEqual('test-client-streamable');
expect(initializeTransaction.contexts?.trace?.data?.['mcp.server.name']).toEqual('Echo-Streamable');
// Verify it's using a StreamableHTTP transport (may be wrapper or inner depending on environment)
expect(initializeTransaction.contexts?.trace?.data?.['mcp.transport']).toMatch(/StreamableHTTPServerTransport/);
});

await test.step('tool handler (tests wrapper transport correlation)', async () => {
// This is the critical test - without the sessionId fix, the span would not be completed
// because onmessage and send see different transport instances (wrapper vs inner)
const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return transactionEvent.transaction === 'tools/call echo' && transport?.includes('StreamableHTTPServerTransport');
});

const toolResult = await client.callTool({
name: 'echo',
arguments: {
message: 'wrapper-transport-test',
},
});

expect(toolResult).toMatchObject({
content: [
{
text: 'Tool echo: wrapper-transport-test',
type: 'text',
},
],
});

const toolTransaction = await toolTransactionPromise;
expect(toolTransaction).toBeDefined();
expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call');
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.name']).toEqual('echo');
// This attribute proves the span was completed with results (sessionId correlation worked)
expect(toolTransaction.contexts?.trace?.data?.['mcp.tool.result.content_count']).toEqual(1);
});

await test.step('resource handler', async () => {
const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'resources/read echo://streamable-test' &&
transport?.includes('StreamableHTTPServerTransport')
);
});

const resourceResult = await client.readResource({
uri: 'echo://streamable-test',
});

expect(resourceResult).toMatchObject({
contents: [{ text: 'Resource echo: streamable-test', uri: 'echo://streamable-test' }],
});

const resourceTransaction = await resourceTransactionPromise;
expect(resourceTransaction).toBeDefined();
expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read');
});

await test.step('prompt handler', async () => {
const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => {
const transport = transactionEvent.contexts?.trace?.data?.['mcp.transport'] as string | undefined;
return (
transactionEvent.transaction === 'prompts/get echo' && transport?.includes('StreamableHTTPServerTransport')
);
});

const promptResult = await client.getPrompt({
name: 'echo',
arguments: {
message: 'streamable-prompt',
},
});

expect(promptResult).toMatchObject({
messages: [
{
content: {
text: 'Please process this message: streamable-prompt',
type: 'text',
},
role: 'user',
},
],
});

const promptTransaction = await promptTransactionPromise;
expect(promptTransaction).toBeDefined();
expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server');
expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get');
});

// Clean up - close the client connection
await client.close();
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
"test:assert": "pnpm test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sentry/node": "latest || *",
"@trpc/server": "10.45.4",
"@trpc/client": "10.45.4",
Expand Down
Loading
Loading