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

Draft
wants to merge 24 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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