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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '135 KB',
limit: '139 KB',
disablePlugins: ['@size-limit/esbuild'],
},
{
Expand Down Expand Up @@ -452,7 +452,7 @@ module.exports = [
path: 'packages/node/build/esm/index.js',
import: createImport('init'),
gzip: true,
limit: '114 KB',
limit: '117 KB',
disablePlugins: ['@size-limit/esbuild'],
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
modifyWebpackConfig: function (config) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';

function mcpSpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpanContainer['items'] {
return container.items.filter(item => item.attributes['sentry.op']?.value === 'mcp.server');
Expand All @@ -20,7 +20,7 @@ function assertInitializeSpan(container: SerializedStreamedSpanContainer): void
expect(initializeSpan.attributes['test.mcp.initialize_spans_started']).toEqual({ type: 'integer', value: 1 });
}

describe('MCP server spans (streamed)', () => {
describe('MCP server spans (streamed, manual instrumentation)', () => {
afterAll(() => {
cleanupChildProcesses();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';

// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration
// auto-instruments the `McpServer` constructor via orchestrion, so spans must appear anyway.
const server = new McpServer({ name: 'Echo', version: '1.0.0' });

server.registerResource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), {}, async uri => ({
contents: [{ uri: uri.href, text: 'Resource echo' }],
}));

server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] }));

async function run() {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '1.0.0' });

await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);

await client.readResource({ uri: 'echo://foobar' });
await client.callTool({ name: 'echo', arguments: {} });

await client.close();
await server.close();
}

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';

function mcpSpan(
container: SerializedStreamedSpanContainer,
method: string,
): SerializedStreamedSpanContainer['items'][number] {
const span = container.items.find(
item =>
item.attributes['sentry.op']?.value === 'mcp.server' && item.attributes['mcp.method.name']?.value === method,
);
// Throwing here makes the (unordered) runner treat this container as "not the one" and wait for
// the next — streaming batches several segments per container, so the target may be elsewhere.
expect(span, `expected an mcp.server span for ${method}`).toBeDefined();
return span!;
}

// The `McpServer` is never manually wrapped — these assertions only pass if the `mcpServer`
// integration auto-instrumented the constructor of the legacy (`@modelcontextprotocol/sdk` v1) SDK.
// Each span type is asserted in its own runner so batched span containers can't consume an envelope
// another assertion still needs.
describe('MCP server spans (streamed, auto-instrumentation, v1)', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmAndCjsTests(
__dirname,
'scenario.mjs',
'instrument.mjs',
(createTestRunner, test) => {
test('auto-instruments the initialize handshake', async () => {
await createTestRunner()
.unordered()
.expect({
span: container => {
const initialize = mcpSpan(container, 'initialize');
expect(initialize.name).toBe('initialize');
expect(initialize.attributes['sentry.origin']).toEqual({
type: 'string',
value: 'auto.function.mcp_server',
});
},
})
.start()
.completed();
});

test('auto-instruments a resource read', async () => {
await createTestRunner()
.unordered()
.expect({
span: container => {
const resource = mcpSpan(container, 'resources/read');
expect(resource.name).toBe('resources/read');
expect(resource.attributes['mcp.resource.uri']?.value).toBe('echo://foobar');
},
})
.start()
.completed();
});

test('auto-instruments a tool call', async () => {
await createTestRunner()
.unordered()
.expect({
span: container => {
const tool = mcpSpan(container, 'tools/call');
expect(tool.name).toBe('tools/call echo');
},
})
.start()
.completed();
});
},
{ additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } },
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Client } from '@modelcontextprotocol/client';
import { InMemoryTransport, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';

// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration
// auto-instruments the `McpServer` constructor via orchestrion, so spans must appear anyway.
const server = new McpServer({ name: 'Echo', version: '1.0.0' });

server.registerResource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), {}, async uri => ({
contents: [{ uri: uri.href, text: 'Resource echo' }],
}));

server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] }));

async function run() {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '1.0.0' });

await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);

await client.readResource({ uri: 'echo://foobar' });
await client.callTool({ name: 'echo', arguments: {} });

await client.close();
await server.close();
}

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';

function mcpSpan(
container: SerializedStreamedSpanContainer,
method: string,
): SerializedStreamedSpanContainer['items'][number] {
const span = container.items.find(
item =>
item.attributes['sentry.op']?.value === 'mcp.server' && item.attributes['mcp.method.name']?.value === method,
);
// Throwing here makes the (unordered) runner treat this container as "not the one" and wait for
// the next — streaming batches several segments per container, so the target may be elsewhere.
expect(span, `expected an mcp.server span for ${method}`).toBeDefined();
return span!;
}

// The `McpServer` is never manually wrapped — these assertions only pass if the `mcpServer`
// integration auto-instrumented the constructor. Each span type is asserted in its own runner so
// batched span containers can't consume an envelope another assertion still needs.
describe('MCP server spans (streamed, auto-instrumentation, v2)', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => {
test('auto-instruments the initialize handshake', async () => {
await createTestRunner()
.unordered()
.expect({
span: container => {
const initialize = mcpSpan(container, 'initialize');
expect(initialize.name).toBe('initialize');
expect(initialize.attributes['sentry.origin']).toEqual({
type: 'string',
value: 'auto.function.mcp_server',
});
},
})
.start()
.completed();
});

test('auto-instruments a resource read', async () => {
await createTestRunner()
.unordered()
.expect({
span: container => {
const resource = mcpSpan(container, 'resources/read');
expect(resource.name).toBe('resources/read');
expect(resource.attributes['mcp.resource.uri']?.value).toBe('echo://foobar');
},
})
.start()
.completed();
});

test('auto-instruments a tool call', async () => {
await createTestRunner()
.unordered()
.expect({
span: container => {
const tool = mcpSpan(container, 'tools/call');
expect(tool.name).toBe('tools/call echo');
},
})
.start()
.completed();
});
});
});
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export {
langGraphIntegration,
createFlueInstrumentation,
mastraIntegration,
mcpServerIntegration,
SentryMastraExporter,
parameterize,
pinoIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export {
langChainIntegration,
langGraphIntegration,
mastraIntegration,
mcpServerIntegration,
SentryMastraExporter,
createFlueInstrumentation,
modulesIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export {
langChainIntegration,
langGraphIntegration,
mastraIntegration,
mcpServerIntegration,
SentryMastraExporter,
createFlueInstrumentation,
modulesIntegration,
Expand Down
37 changes: 33 additions & 4 deletions packages/core/src/integrations/mcp-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,31 @@ import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport } from '.
import { validateMcpServerInstance } from './validation';

/**
* Tracks wrapped MCP server instances to prevent double-wrapping
* Maps each wrapped MCP server instance to the mutable capture options that the transport
* instrumentation reads (per message). Prevents double-wrapping while still letting a later
* `wrapMcpServerWithSentry` fill in options left unset by an earlier wrap — e.g. an auto-wrap at
* construction installs no explicit options, so a manual override still applies.
* @internal
*/
const wrappedMcpServerInstances = new WeakSet();
const wrappedMcpServerOptions = new WeakMap<object, McpServerWrapperOptions>();

/**
* Fill in capture options not explicitly set by an earlier wrap. Only unset fields are written, so
* the first explicit `recordInputs`/`recordOutputs` wins, but an auto-wrap that set neither still
* yields to a later manual override. Mutating the stored object updates the live transport
* instrumentation, which reads it per message.
*/
function applyMissingMcpOptions(target: McpServerWrapperOptions, source: McpServerWrapperOptions | undefined): void {
if (!source) {
return;
}
if (target.recordInputs === undefined && source.recordInputs !== undefined) {
target.recordInputs = source.recordInputs;
}
if (target.recordOutputs === undefined && source.recordOutputs !== undefined) {
target.recordOutputs = source.recordOutputs;
}
}

function instrumentTransport(transport: MCPTransport, options: McpServerWrapperOptions): void {
wrapTransportOnMessage(transport, options);
Expand Down Expand Up @@ -95,6 +116,12 @@ function interceptTransportStart(transport: MCPTransport, beforeStart: () => voi
* wraps any already-registered ones. Wrapping at construction time is recommended by
* convention (consistent with other SDK integrations), but is not required.
*
* Calling this more than once on the same instance never patches it twice. Options behave like a
* snapshot from the first *explicit* wrap: the first `recordInputs`/`recordOutputs` value set for a
* field wins, but a field left unset can still be filled by a later call. So when the SDK auto-wraps
* the server at construction (via the `mcpServer` integration) with no explicit options, a later
* manual `wrapMcpServerWithSentry(server, { recordInputs, recordOutputs })` still applies.
*
* @example
* ```typescript
* import * as Sentry from '@sentry/core';
Expand Down Expand Up @@ -123,7 +150,9 @@ function interceptTransportStart(transport: MCPTransport, beforeStart: () => voi
* @returns Instrumented server instance (same reference)
*/
export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S, options?: McpServerWrapperOptions): S {
if (wrappedMcpServerInstances.has(mcpServerInstance)) {
const existingOptions = wrappedMcpServerOptions.get(mcpServerInstance);
if (existingOptions) {
applyMissingMcpOptions(existingOptions, options);
return mcpServerInstance;
}

Expand All @@ -133,6 +162,7 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S,

const serverInstance = mcpServerInstance as MCPServerInstance;
const captureOptions: McpServerWrapperOptions = { ...options };
wrappedMcpServerOptions.set(mcpServerInstance, captureOptions);

fill(serverInstance, 'connect', originalConnect => {
return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) {
Expand Down Expand Up @@ -167,6 +197,5 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S,

wrapExistingHandlers(serverInstance);

wrappedMcpServerInstances.add(mcpServerInstance);
return mcpServerInstance;
}
Original file line number Diff line number Diff line change
Expand Up @@ -389,4 +389,31 @@ describe('MCP Server Capture Policy', () => {
);
expectToolResult(span);
});

it('applies a later explicit override when the first wrap set no options (auto-instrumentation)', async () => {
// The `mcpServer` integration auto-wraps at construction with no options; a manual
// `wrapMcpServerWithSentry(server, { recordInputs: false, recordOutputs: false })` must still opt out,
// even against a client whose data-collection settings would otherwise capture both.
const server = createMockMcpServer();
wrapMcpServerWithSentry(server);
wrapMcpServerWithSentry(server, { recordInputs: false, recordOutputs: false });
const transport = await connectServer(server, 'capture-policy-auto-override');
const recordingScope = createClientScope(true, true);
const span = queueInactiveSpan();

receiveToolCall(transport, recordingScope, {
id: 'auto-override-request',
location: 'Riga, Latvia',
});
await sendToolResult(transport, recordingScope, {
id: 'auto-override-request',
text: 'Private forecast for Riga',
});

expect(startInactiveSpanSpy).toHaveBeenCalledOnce();
expect(startInactiveSpanSpy).toHaveBeenCalledWith(
buildToolSpanConfig({ id: 'auto-override-request', sessionId: 'capture-policy-auto-override' }),
);
expectToolResult(span);
});
});
Loading
Loading