Skip to content

feat(core): MCP server instrumentation without breaking Miniflare #16817

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 67 commits into from
Jul 28, 2025
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
dff2080
feat(mcp-server): Enhance transport handling and request instrumentation
betegon Jun 27, 2025
cb28ccc
test transport layer
betegon Jul 1, 2025
1480b78
refactor(mcp-server.test): Simplify test setup by using beforeEach fo…
betegon Jul 1, 2025
5a97d69
test(mcp-server): Add tests for span creation and semantic convention…
betegon Jul 1, 2025
03077f8
test(mcp-server): Update tests to control transport connection in ind…
betegon Jul 2, 2025
cac9bd0
test(mcp-server): Refine span attributes and transport details
betegon Jul 2, 2025
37ef9a9
test(mcp-server): Replace direct tracing module calls with spies for …
betegon Jul 2, 2025
ac015ce
feat(mcp-server): Add TypeScript type definitions for MCP server inst…
betegon Jul 2, 2025
c2f3e82
feat(mcp-server): Introduce MCP attributes and methods for enhanced t…
betegon Jul 2, 2025
094574f
test(mcp-server): Add tests for span creation with various notificati…
betegon Jul 2, 2025
9972b09
test(mcp-server): Update test to use spy for startSpan
betegon Jul 2, 2025
ef52da5
refactor(mcp-server): improve span handling and attribute extraction
betegon Jul 2, 2025
aee709b
simplify attributes
betegon Jul 2, 2025
edc4e3c
refactor(mcp-server): improve types
betegon Jul 2, 2025
62ca0f3
refactor(mcp-server): refactor span handling and utility functions fo…
betegon Jul 2, 2025
08c39f1
remove unused import and comment legacy support
betegon Jul 3, 2025
fe2c865
refactor(mcp-server): improve notification span handling and set attr…
betegon Jul 3, 2025
ec3cb6f
refactor(mcp-server): span and attribute creation
betegon Jul 3, 2025
e193118
refactor(mcp-server): method configuration and argument extraction fo…
betegon Jul 3, 2025
347422c
refactor(mcp-server): improve transport type handling and add tests f…
betegon Jul 3, 2025
02cb799
Merge branch 'develop' into bete/mcp-server-semantic-convention
betegon Jul 3, 2025
9776402
fix lint
betegon Jul 3, 2025
d4c74a9
refactor(mcp-server): use fill for method wrapping for transport hand…
betegon Jul 4, 2025
25297f6
Merge branch 'develop' into bete/mcp-server-semantic-convention-fill
betegon Jul 4, 2025
7b5fd86
move files to intregations directory
betegon Jul 8, 2025
97990c6
use parseStringToURLObject for url handling and update test
betegon Jul 8, 2025
1efda74
(draft) fix span duration
betegon Jul 8, 2025
aa0a9fd
Add tool call results and MCP spans duration to cover their children …
betegon Jul 9, 2025
4973a60
Implement MCP server attribute extraction and correlation system. New…
betegon Jul 10, 2025
de1b87f
Refactor MCP server integration. improve attribute extraction and spa…
betegon Jul 10, 2025
7624a3e
fix lint
betegon Jul 10, 2025
d94a4d0
prettier
betegon Jul 10, 2025
6c67f51
Implement PII filtering for MCP server spans. Introduce a new utility…
betegon Jul 10, 2025
b305410
Merge branch 'develop' into bete/mcp-server-semantic-convention-fill
betegon Jul 11, 2025
24bff02
add debug logger
betegon Jul 11, 2025
670e6b1
feat(mcp-server): tool result handling and error capturing
betegon Jul 11, 2025
f73fdc6
add tests
betegon Jul 11, 2025
ff4823e
fix lint
betegon Jul 11, 2025
b62ba22
fix lint
betegon Jul 11, 2025
1f48e0d
Update MCP e2e tests to match new semantic conventions
betegon Jul 14, 2025
cdb920b
Merge branch 'develop' into bete/mcp-server-semantic-convention-fill
betegon Jul 14, 2025
4f7042a
fix (mcp-server): pass original context to createErrorCapturingHandler
betegon Jul 15, 2025
790f2b3
Merge branch 'develop' into bete/mcp-server-semantic-convention-fill
betegon Jul 15, 2025
8524991
refactor types
betegon Jul 16, 2025
477f1f2
Set span status to error when capturing errors and added MCP method n…
betegon Jul 17, 2025
0ea5876
fix: capture to only JSON-RPC server-side errors
betegon Jul 17, 2025
2d435ca
Merge branch 'develop' into bete/mcp-server-semantic-convention-fill
betegon Jul 17, 2025
284e763
update logger
betegon Jul 17, 2025
ec5d736
fix lint
betegon Jul 17, 2025
d5fcdc3
fix: create a map for each transport to identify different sessions t…
betegon Jul 17, 2025
612fc8c
lint fix
betegon Jul 18, 2025
1cd1048
ref: improve jsdoc and refactor attributes to be in a single file
betegon Jul 18, 2025
683a895
restore handlerArgs argument
betegon Jul 18, 2025
61587a9
use reduce to improve performance
betegon Jul 18, 2025
64bfeda
remove legacy test
betegon Jul 18, 2025
5a6f4ab
Get and persist session data + refactor to fit the max 300 lines file…
betegon Jul 22, 2025
01e1149
fix lint
betegon Jul 22, 2025
b6f19ca
Session data tests
betegon Jul 22, 2025
f573506
fix wrapTransportSend to handle all transport.send arguments
betegon Jul 22, 2025
b48a065
fix lint
betegon Jul 22, 2025
00d21f0
Merge branch 'develop' into bete/mcp-server-semantic-convention-fill
betegon Jul 22, 2025
5438817
Merge branch 'develop' into bete/mcp-server-semantic-convention-fill
betegon Jul 28, 2025
4dc6d07
wait until the transport is set up
betegon Jul 28, 2025
3494601
body parsing middleware
betegon Jul 28, 2025
55aa5ac
Pass request body to transport's handlePostMessage method
betegon Jul 28, 2025
ca63b75
match object
betegon Jul 28, 2025
cfceba7
fix attribute naming
betegon Jul 28, 2025
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
364 changes: 101 additions & 263 deletions packages/core/src/mcp-server.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,17 @@
import { DEBUG_BUILD } from './debug-build';
import type {
ExtraHandlerData,
MCPServerInstance,
MCPTransport,
} from './utils/mcp-server/types';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from './semanticAttributes';
import { startSpan, withActiveSpan } from './tracing';
import type { Span } from './types-hoist/span';
import { logger } from './utils/logger';
import { getActiveSpan } from './utils/spanUtils';

interface MCPTransport {
// The first argument is a JSON RPC message
onmessage?: (...args: unknown[]) => void;
onclose?: (...args: unknown[]) => void;
sessionId?: string;
}

interface MCPServerInstance {
// The first arg is always a name, the last arg should always be a callback function (ie a handler).
// TODO: We could also make use of the resource uri argument somehow.
resource: (name: string, ...args: unknown[]) => void;
// The first arg is always a name, the last arg should always be a callback function (ie a handler).
tool: (name: string, ...args: unknown[]) => void;
// The first arg is always a name, the last arg should always be a callback function (ie a handler).
prompt: (name: string, ...args: unknown[]) => void;
connect(transport: MCPTransport): Promise<void>;
}
createMcpNotificationSpan,
createMcpOutgoingNotificationSpan,
createMcpServerSpan,
isJsonRpcNotification,
isJsonRpcRequest,
validateMcpServerInstance,
} from './utils/mcp-server/utils';
import { fill } from './utils/object';

const wrappedMcpServerInstances = new WeakSet();

Expand All @@ -40,253 +26,105 @@ export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S):
return mcpServerInstance;
}

if (!isMcpServerInstance(mcpServerInstance)) {
DEBUG_BUILD && logger.warn('Did not patch MCP server. Interface is incompatible.');
if (!validateMcpServerInstance(mcpServerInstance)) {
return mcpServerInstance;
}

// eslint-disable-next-line @typescript-eslint/unbound-method
mcpServerInstance.connect = new Proxy(mcpServerInstance.connect, {
apply(target, thisArg, argArray) {
const [transport, ...restArgs] = argArray as [MCPTransport, ...unknown[]];

if (!transport.onclose) {
transport.onclose = () => {
if (transport.sessionId) {
handleTransportOnClose(transport.sessionId);
}
};
const serverInstance = mcpServerInstance as MCPServerInstance;

fill(serverInstance, 'connect', (originalConnect) => {
return async function(this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) {
const result = await originalConnect.call(this, transport, ...restArgs);

if (transport.onmessage) {
fill(transport, 'onmessage', (originalOnMessage) => {
return function(this: MCPTransport, jsonRpcMessage: unknown, extra?: unknown) {
if (isJsonRpcRequest(jsonRpcMessage)) {
return createMcpServerSpan(jsonRpcMessage, this, extra as ExtraHandlerData, () => {
return originalOnMessage.call(this, jsonRpcMessage, extra);
});
}
if (isJsonRpcNotification(jsonRpcMessage)) {
return createMcpNotificationSpan(jsonRpcMessage, this, extra as ExtraHandlerData, () => {
return originalOnMessage.call(this, jsonRpcMessage, extra);
});
}
return originalOnMessage.call(this, jsonRpcMessage, extra);
};
});
}

if (!transport.onmessage) {
transport.onmessage = jsonRpcMessage => {
if (transport.sessionId && isJsonRPCMessageWithRequestId(jsonRpcMessage)) {
handleTransportOnMessage(transport.sessionId, jsonRpcMessage.id);
}
};
if (transport.send) {
fill(transport, 'send', (originalSend) => {
return async function(this: MCPTransport, message: unknown) {
if (isJsonRpcNotification(message)) {
return createMcpOutgoingNotificationSpan(message, this, () => {
return originalSend.call(this, message);
});
}
return originalSend.call(this, message);
};
});
}

const patchedTransport = new Proxy(transport, {
set(target, key, value) {
if (key === 'onmessage') {
target[key] = new Proxy(value, {
apply(onMessageTarget, onMessageThisArg, onMessageArgArray) {
const [jsonRpcMessage] = onMessageArgArray;
if (transport.sessionId && isJsonRPCMessageWithRequestId(jsonRpcMessage)) {
handleTransportOnMessage(transport.sessionId, jsonRpcMessage.id);
}
return Reflect.apply(onMessageTarget, onMessageThisArg, onMessageArgArray);
},
});
} else if (key === 'onclose') {
target[key] = new Proxy(value, {
apply(onCloseTarget, onCloseThisArg, onCloseArgArray) {
if (transport.sessionId) {
handleTransportOnClose(transport.sessionId);
}
return Reflect.apply(onCloseTarget, onCloseThisArg, onCloseArgArray);
},
});
} else {
target[key as keyof MCPTransport] = value;
}
return true;
},
});

return Reflect.apply(target, thisArg, [patchedTransport, ...restArgs]);
},
});

mcpServerInstance.resource = new Proxy(mcpServerInstance.resource, {
apply(target, thisArg, argArray) {
const resourceName: unknown = argArray[0];
const resourceHandler: unknown = argArray[argArray.length - 1];

if (typeof resourceName !== 'string' || typeof resourceHandler !== 'function') {
return target.apply(thisArg, argArray);
if (transport.onclose) {
fill(transport, 'onclose', (originalOnClose) => {
return function(this: MCPTransport, ...args: unknown[]) {
return originalOnClose.call(this, ...args);
};
});
}

const wrappedResourceHandler = new Proxy(resourceHandler, {
apply(resourceHandlerTarget, resourceHandlerThisArg, resourceHandlerArgArray) {
const extraHandlerDataWithRequestId = resourceHandlerArgArray.find(isExtraHandlerDataWithRequestId);
return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => {
return startSpan(
{
name: `mcp-server/resource:${resourceName}`,
forceTransaction: true,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
'mcp_server.resource': resourceName,
},
},
() => resourceHandlerTarget.apply(resourceHandlerThisArg, resourceHandlerArgArray),
);
});
},
});

return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedResourceHandler]);
},
});

mcpServerInstance.tool = new Proxy(mcpServerInstance.tool, {
apply(target, thisArg, argArray) {
const toolName: unknown = argArray[0];
const toolHandler: unknown = argArray[argArray.length - 1];

if (typeof toolName !== 'string' || typeof toolHandler !== 'function') {
return target.apply(thisArg, argArray);
}

const wrappedToolHandler = new Proxy(toolHandler, {
apply(toolHandlerTarget, toolHandlerThisArg, toolHandlerArgArray) {
const extraHandlerDataWithRequestId = toolHandlerArgArray.find(isExtraHandlerDataWithRequestId);
return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => {
return startSpan(
{
name: `mcp-server/tool:${toolName}`,
forceTransaction: true,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
'mcp_server.tool': toolName,
},
},
() => toolHandlerTarget.apply(toolHandlerThisArg, toolHandlerArgArray),
);
});
},
});

return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedToolHandler]);
},
});

mcpServerInstance.prompt = new Proxy(mcpServerInstance.prompt, {
apply(target, thisArg, argArray) {
const promptName: unknown = argArray[0];
const promptHandler: unknown = argArray[argArray.length - 1];

if (typeof promptName !== 'string' || typeof promptHandler !== 'function') {
return target.apply(thisArg, argArray);
}

const wrappedPromptHandler = new Proxy(promptHandler, {
apply(promptHandlerTarget, promptHandlerThisArg, promptHandlerArgArray) {
const extraHandlerDataWithRequestId = promptHandlerArgArray.find(isExtraHandlerDataWithRequestId);
return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => {
return startSpan(
{
name: `mcp-server/prompt:${promptName}`,
forceTransaction: true,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
'mcp_server.prompt': promptName,
},
},
() => promptHandlerTarget.apply(promptHandlerThisArg, promptHandlerArgArray),
);
});
},
});

return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedPromptHandler]);
},
return result;
};
});

wrappedMcpServerInstances.add(mcpServerInstance);

return mcpServerInstance as S;
}

function isMcpServerInstance(mcpServerInstance: unknown): mcpServerInstance is MCPServerInstance {
return (
typeof mcpServerInstance === 'object' &&
mcpServerInstance !== null &&
'resource' in mcpServerInstance &&
typeof mcpServerInstance.resource === 'function' &&
'tool' in mcpServerInstance &&
typeof mcpServerInstance.tool === 'function' &&
'prompt' in mcpServerInstance &&
typeof mcpServerInstance.prompt === 'function' &&
'connect' in mcpServerInstance &&
typeof mcpServerInstance.connect === 'function'
);
}

function isJsonRPCMessageWithRequestId(target: unknown): target is { id: RequestId } {
return (
typeof target === 'object' &&
target !== null &&
'id' in target &&
(typeof target.id === 'number' || typeof target.id === 'string')
);
}

interface ExtraHandlerDataWithRequestId {
sessionId: SessionId;
requestId: RequestId;
}

// Note that not all versions of the MCP library have `requestId` as a field on the extra data.
function isExtraHandlerDataWithRequestId(target: unknown): target is ExtraHandlerDataWithRequestId {
return (
typeof target === 'object' &&
target !== null &&
'sessionId' in target &&
typeof target.sessionId === 'string' &&
'requestId' in target &&
(typeof target.requestId === 'number' || typeof target.requestId === 'string')
);
}

type SessionId = string;
type RequestId = string | number;

const sessionAndRequestToRequestParentSpanMap = new Map<SessionId, Map<RequestId, Span>>();

function handleTransportOnClose(sessionId: SessionId): void {
sessionAndRequestToRequestParentSpanMap.delete(sessionId);
}

function handleTransportOnMessage(sessionId: SessionId, requestId: RequestId): void {
const activeSpan = getActiveSpan();
if (activeSpan) {
const requestIdToSpanMap = sessionAndRequestToRequestParentSpanMap.get(sessionId) ?? new Map();
requestIdToSpanMap.set(requestId, activeSpan);
sessionAndRequestToRequestParentSpanMap.set(sessionId, requestIdToSpanMap);
}
}

function associateContextWithRequestSpan<T>(
extraHandlerData: ExtraHandlerDataWithRequestId | undefined,
cb: () => T,
): T {
if (extraHandlerData) {
const { sessionId, requestId } = extraHandlerData;
const requestIdSpanMap = sessionAndRequestToRequestParentSpanMap.get(sessionId);

if (!requestIdSpanMap) {
return cb();
}

const span = requestIdSpanMap.get(requestId);
if (!span) {
return cb();
}

// remove the span from the map so it can be garbage collected
requestIdSpanMap.delete(requestId);
return withActiveSpan(span, () => {
return cb();
});
}

return cb();
}
// =============================================================================
// SESSION AND REQUEST CORRELATION (Legacy support)
// =============================================================================

// const sessionAndRequestToRequestParentSpanMap = new Map<SessionId, Map<string, Span>>();

// function handleTransportOnClose(sessionId: SessionId): void {
// sessionAndRequestToRequestParentSpanMap.delete(sessionId);
// }

// TODO(bete): refactor this and associateContextWithRequestSpan to use the new span API.
// function handleTransportOnMessage(sessionId: SessionId, requestId: string): void {
// const activeSpan = getActiveSpan();
// if (activeSpan) {
// const requestIdToSpanMap = sessionAndRequestToRequestParentSpanMap.get(sessionId) ?? new Map();
// requestIdToSpanMap.set(requestId, activeSpan);
// sessionAndRequestToRequestParentSpanMap.set(sessionId, requestIdToSpanMap);
// }
// }

// function associateContextWithRequestSpan<T>(
// extraHandlerData: { sessionId: SessionId; requestId: string } | undefined,
// cb: () => T,
// ): T {
// if (extraHandlerData) {
// const { sessionId, requestId } = extraHandlerData;
// const requestIdSpanMap = sessionAndRequestToRequestParentSpanMap.get(sessionId);

// if (!requestIdSpanMap) {
// return cb();
// }

// const span = requestIdSpanMap.get(requestId);
// if (!span) {
// return cb();
// }

// // remove the span from the map so it can be garbage collected
// requestIdSpanMap.delete(requestId);
// return withActiveSpan(span, () => {
// return cb();
// });
// }

// return cb();
// }
Loading
Loading