Skip to content
Draft
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
71 changes: 71 additions & 0 deletions packages/core/src/integrations/mcp-server/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,74 @@ export function wrapAllMCPHandlers(serverInstance: MCPServerInstance): void {
wrapResourceHandlers(serverInstance);
wrapPromptHandlers(serverInstance);
}

/**
* Retroactively wraps handlers on tools, resources, and prompts that were registered
* before `wrapMcpServerWithSentry` was called.
*
* The MCP SDK stores registered entries in private maps and invokes them via the entry's
* own property at call time — `executor` for tools, `readCallback` for resources, and
* `handler` for prompts. Replacing those properties
* in-place is therefore equivalent to having wrapped the original registration call.
*
* NOTE: This intentionally accesses private MCP SDK internals (`_registeredTools` etc.).
* The properties and their shapes are verified against @modelcontextprotocol/sdk source:
* https://github.com/modelcontextprotocol/typescript-sdk/blob/2c0c481cb9dbfd15c8613f765c940a5f5bace94d/packages/server/src/server/mcp.ts#L304
* When upgrading the MCP SDK, re-verify that these internal maps and their callable
* properties still exist and are invoked directly (not captured by closure at registration).
* All access is defensive — if a property is absent or not a function we skip silently.
* @internal
*/
export function wrapExistingHandlers(serverInstance: MCPServerInstance): void {
const server = serverInstance as unknown as Record<string, unknown>;

// Tools: MCP SDK calls registeredTool.executor (generated from handler at registration time)
const registeredTools = server['_registeredTools'];
if (registeredTools && typeof registeredTools === 'object') {
for (const [name, tool] of Object.entries(registeredTools as Record<string, Record<string, unknown>>)) {
if (typeof tool['executor'] === 'function') {
tool['executor'] = createWrappedHandler(tool['executor'] as MCPHandler, 'registerTool', name);
}
}
}

// Resources: MCP SDK calls registeredResource.readCallback
const registeredResources = server['_registeredResources'];
if (registeredResources && typeof registeredResources === 'object') {
for (const [name, resource] of Object.entries(registeredResources as Record<string, Record<string, unknown>>)) {
if (typeof resource['readCallback'] === 'function') {
resource['readCallback'] = createWrappedHandler(
resource['readCallback'] as MCPHandler,
'registerResource',
name,
);
}
}
}

// Resource templates: MCP SDK calls registeredResourceTemplate.readCallback
const registeredResourceTemplates = server['_registeredResourceTemplates'];
if (registeredResourceTemplates && typeof registeredResourceTemplates === 'object') {
for (const [name, template] of Object.entries(
registeredResourceTemplates as Record<string, Record<string, unknown>>,
)) {
if (typeof template['readCallback'] === 'function') {
template['readCallback'] = createWrappedHandler(
template['readCallback'] as MCPHandler,
'registerResource',
name,
);
}
}
}

// Prompts: MCP SDK calls registeredPrompt.handler
const registeredPrompts = server['_registeredPrompts'];
if (registeredPrompts && typeof registeredPrompts === 'object') {
for (const [name, prompt] of Object.entries(registeredPrompts as Record<string, Record<string, unknown>>)) {
if (typeof prompt['handler'] === 'function') {
prompt['handler'] = createWrappedHandler(prompt['handler'] as MCPHandler, 'registerPrompt', name);
}
}
}
}
13 changes: 11 additions & 2 deletions packages/core/src/integrations/mcp-server/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getClient } from '../../currentScopes';
import { fill } from '../../utils/object';
import { wrapAllMCPHandlers } from './handlers';
import { wrapAllMCPHandlers, wrapExistingHandlers } from './handlers';
import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport';
import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions } from './types';
import { validateMcpServerInstance } from './validation';
Expand All @@ -18,17 +18,24 @@ const wrappedMcpServerInstances = new WeakSet();
* and versions that expose the newer `registerTool`/`registerResource`/`registerPrompt` API (introduced in 1.x, sole API in 2.x).
* Automatically instruments transport methods and handler functions for comprehensive monitoring.
*
* Both call orderings are supported: wrapping before or after registering tools, resources,
* and prompts. Sentry patches the registration methods for future handlers and retroactively
* wraps any already-registered ones. Wrapping at construction time is recommended by
* convention (consistent with other SDK integrations), but is not required.
*
* @example
* ```typescript
* import * as Sentry from '@sentry/core';
* import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
* import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
*
* // Default: inputs/outputs captured based on sendDefaultPii option
* // Wrap first, then register tools — this is the correct order
* const server = Sentry.wrapMcpServerWithSentry(
* new McpServer({ name: "my-server", version: "1.0.0" })
* );
*
* server.registerTool('my-tool', schema, handler);
*
* // Explicitly control input/output capture
* const server = Sentry.wrapMcpServerWithSentry(
* new McpServer({ name: "my-server", version: "1.0.0" }),
Expand Down Expand Up @@ -80,6 +87,8 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S,

wrapAllMCPHandlers(serverInstance);

wrapExistingHandlers(serverInstance);

wrappedMcpServerInstances.add(mcpServerInstance);
return mcpServerInstance;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as currentScopes from '../../../../src/currentScopes';
import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server';
import * as tracingModule from '../../../../src/tracing';
import { createMockMcpServer, createMockMcpServerWithRegisterApi } from './testUtils';
import {
createMockMcpServer,
createMockMcpServerWithPreregisteredHandlers,
createMockMcpServerWithRegisterApi,
} from './testUtils';

describe('wrapMcpServerWithSentry', () => {
const startSpanSpy = vi.spyOn(tracingModule, 'startSpan');
Expand Down Expand Up @@ -145,6 +149,41 @@ describe('wrapMcpServerWithSentry', () => {
});
});

describe('Retroactive handler wrapping (handlers registered before wrapMcpServerWithSentry)', () => {
it('should replace executor/readCallback/handler on pre-registered entries with wrapped versions', () => {
const server = createMockMcpServerWithPreregisteredHandlers();
const { toolExecutor, resourceReadCallback, resourceTemplateReadCallback, promptHandler } = server._originals;

wrapMcpServerWithSentry(server);

expect(server._registeredTools['my-tool']!.executor).not.toBe(toolExecutor);
expect(server._registeredResources['res://my-resource']!.readCallback).not.toBe(resourceReadCallback);
expect(server._registeredResourceTemplates['my-template']!.readCallback).not.toBe(resourceTemplateReadCallback);
expect(server._registeredPrompts['my-prompt']!.handler).not.toBe(promptHandler);
});

it('should still wrap the registration methods for future handlers', () => {
const server = createMockMcpServerWithPreregisteredHandlers();
const originalRegisterTool = server.registerTool;

wrapMcpServerWithSentry(server);

expect(server.registerTool).not.toBe(originalRegisterTool);
});

it('should not double-wrap if called twice on the same instance with pre-registered handlers', () => {
const server = createMockMcpServerWithPreregisteredHandlers();

wrapMcpServerWithSentry(server);
const executorAfterFirstWrap = server._registeredTools['my-tool']!.executor;

wrapMcpServerWithSentry(server);
const executorAfterSecondWrap = server._registeredTools['my-tool']!.executor;

expect(executorAfterFirstWrap).toBe(executorAfterSecondWrap);
});
});

describe('Handler Wrapping (register* API)', () => {
let mockServer: ReturnType<typeof createMockMcpServerWithRegisterApi>;
let wrappedServer: ReturnType<typeof createMockMcpServerWithRegisterApi>;
Expand Down
35 changes: 35 additions & 0 deletions packages/core/test/lib/integrations/mcp-server/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,41 @@ export function createMockMcpServer() {
};
}

/**
* Create a mock MCP server that simulates already having tools/resources/prompts registered
* (i.e. wrapMcpServerWithSentry is called after registration). Mirrors the internal shape
* used by McpServer v2: tools have an `executor`, resources/prompts have `readCallback`/`handler`.
*/
export function createMockMcpServerWithPreregisteredHandlers() {
const toolExecutor = vi.fn().mockResolvedValue({ content: [] });
const resourceReadCallback = vi.fn().mockResolvedValue({ contents: [] });
const resourceTemplateReadCallback = vi.fn().mockResolvedValue({ contents: [] });
const promptHandler = vi.fn().mockResolvedValue({ messages: [] });

return {
registerTool: vi.fn(),
registerResource: vi.fn(),
registerPrompt: vi.fn(),
connect: vi.fn().mockResolvedValue(undefined),
server: { setRequestHandler: vi.fn() },
// Simulated internal registries (mirrors McpServer v2 private fields)
_registeredTools: {
'my-tool': { executor: toolExecutor },
},
_registeredResources: {
'res://my-resource': { readCallback: resourceReadCallback },
},
_registeredResourceTemplates: {
'my-template': { readCallback: resourceTemplateReadCallback },
},
_registeredPrompts: {
'my-prompt': { handler: promptHandler },
},
// Expose the original fns so tests can assert wrapping happened
_originals: { toolExecutor, resourceReadCallback, resourceTemplateReadCallback, promptHandler },
};
}

/**
* Create a mock MCP server instance using the new register* API (SDK >=1.x / 2.x)
*/
Expand Down
Loading