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

Open
wants to merge 43 commits into
base: develop
Choose a base branch
from

Conversation

betegon
Copy link
Member

@betegon betegon commented Jul 4, 2025

Closes #16826, #16654, #16666

Different approach from #16807 .

Using Proxy was causing issues in cloudflare #16182.

Now using fill we shouldn't have those problems as fill doesn't create a new wrapper object with a different identity, so now:

  1. fill just replaces the method on the existing object
  2. The transport object keeps its original identity
  3. When transport.start() runs and accesses private fields, this is still the original transport object
  4. The WeakMap recognizes it as the same object that owns the private fields

What's inside

  • Support for new MCP SDK methods (mcpServerInstance.tool(), mcpServerInstance.resource(), etc.)
  • Tracing instrumentation
  • Error handling

Tracing

It follows OTEL semantic conventions for MCP and adds more attributes we thought are useful.

It also handles PII based on user setting of sendDefaultPii.

Tracing flow

  1. Transport receives tools/call request (id: 2)
  2. Create INACTIVE mcp.server span
  3. Store span inrequestIdToSpanMap[2] = { span, method: "tools/call", startTime }
  4. Execute handler with span context (handler gets requestId: 2)
  5. Handler finds span using requestId: 2
  6. Tool execution happens within span context
  7. Response sent with tool results
  8. completeSpanWithToolResults(2, result) enriches and completes span

Error handling

  1. error capture - errorCapture.ts
  • Non-invasive error reporting that never interferes with MCP service operation
  • Error context with MCP-specific metadata
  • PII filtering respects sendDefaultPii settings
  • Resilient to Sentry failures (wrapped in try-catch)
  1. Tool execution error capturing - handlers.ts
  • Captures exceptions thrown during tool execution
  • Preserves normal MCP behaviour (errors converted to isError: true)
  • Includes tool name, arguments, and request context
  • Handles both sync and async tool handlers
  1. Transport error instrumentation - transport.ts
  • Captures connection errors and network failures
  • Intercepts JSON-RPC error responses
  • Includes transport type and session information
  • Handles error responses being sent back to clients

betegon added 24 commits June 27, 2025 20:46
…ibute names to match OTEL draft semantic convention
@betegon betegon self-assigned this Jul 4, 2025
@betegon betegon requested a review from AbhiPrasad July 4, 2025 18:28
@betegon betegon changed the title Bete/mcp server semantic convention fill feat(core): MCP server instrumentation without breaking Miniflare Jul 4, 2025
Copy link
Member

@AbhiPrasad AbhiPrasad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice, all good from my eyes in overall direction!

@betegon
Copy link
Member Author

betegon commented Jul 7, 2025

Thanks @AbhiPrasad! Will keep working on this and close the other PR using Proxy. Will apply your suggestions here!

betegon added 17 commits July 8, 2025 14:04
…duration. adds configuration, extraction, and transport utilities. Introduce span creation functions and improves method wrapping for improved telemetry.
… files for attribute extraction, correlation, and handler wrapping, removing deprecated utilities and configuration files. Improve transport instrumentation for better telemetry and span handling.
…n handling functions. Update type definitions and separate method wrapping for transport handlers.
… to remove sensitive data based on the sendDefaultPii setting.
- Introduced new attributes for tool result content count and error status.
- Updated attribute extraction functions to utilize new constants for better maintainability.
- Added error capturing utilities to handle tool execution errors and transport errors gracefully.
@betegon betegon marked this pull request as ready for review July 14, 2025 16:17
cursor[bot]

This comment was marked as outdated.

Copy link
Member

@AbhiPrasad AbhiPrasad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1st pass.

Before I review further, I'd like to see the develop docs PR so that I can make sure all the attributes are being added as we expect. Right now it's a bit hard to know if an attribute is missing or not.

I assume much of this is AI generated, which is fine, but let's make sure we clean the comments up (and expand the jsdoc string appropriately).

@@ -0,0 +1,166 @@
/**
* types for MCP server instrumentation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these vendored in from the mcp package? If so we need to put the mcp library license + sha we grabbed the types from in this file.

if (isJsonRpcRequest(jsonRpcMessage)) {
const messageTyped = jsonRpcMessage as { method: string; id: string | number };

// Create isolation scope for this request (standard Sentry pattern)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: Instead of inline comments like this, I would prefer if we added notes in the jsdoc to document behaviour. Right now this comment is a bit redundant with the code.

Ditto with other instances of this.


// Use Object.fromEntries with filter for a more functional approach
return Object.fromEntries(
Object.entries(spanData).filter(([key]) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: I would prefer if we called reduce on Object.entries(spanData) to do this operation instead of calling filter + Object.fromEntries on the constructed array.


captureException(error, {
tags: {
mcp_error_type: errorType || 'handler_execution',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sdk should not be setting tags by default. Tags are meant to be only set by users.

What we can instead do is set a mechanism: https://develop.sentry.dev/sdk/data-model/event-payloads/exception/#exception-mechanism.

*
* Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package.
*/
export function wrapMcpServerWithSentry<S extends object>(mcpServerInstance: S): S {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add an example usage snippet to the jsdoc here.

@betegon
Copy link
Member Author

betegon commented Jul 15, 2025

@AbhiPrasad wdyt of this? Bugbot said that wrapTransportOnMessage, wrapTransportSend, etc. only work if the transport methods are defined at connect() time, and that the fill doesn’t capture handlers set lazy after that.

From what I’ve tested, fill works fine. the MCP server sets onmessage, send... during .connect() (coed here), and wrapMcpServerWithSentry() is called before that, so the methods are already in place when we wrap them.

Do you think it makes sense to get lazy loading handlers? Not sure if it’s worthy adding more complexity for this. Cursor suggested something like a wrapTransportProperty helper that uses property descriptors like:

function wrapTransportProperty(obj, key, wrapFn) {
  let current = obj[key];
  let wrapped = false;

  Object.defineProperty(obj, key, {
    configurable: true,
    enumerable: true,
    get() {
      return current;
    },
    set(newValue) {
      if (typeof newValue === 'function' && !wrapped) {
        current = wrapFn(newValue);
        wrapped = true;
      } else {
        current = newValue;
        wrapped = false;
      }
    }
  });

  // in case the property was already assigned before wrapping
  if (typeof current === 'function') {
    obj[key] = current;
  }
}

@AbhiPrasad
Copy link
Member

only work if the transport methods are defined at connect() time, and that the fill doesn’t capture handlers set lazy after that

yeah as long as it exists when we wrap it I think we are fine.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Session ID Missing Causes Span Collisions

The requestIdToSpanMap is now keyed solely by requestId. Since requestIds are only guaranteed to be unique per session and are frequently reused across different MCP sessions, this leads to collisions. When multiple concurrent sessions use the same requestId, the second request overwrites the first session's span data. This results in:

  • Incorrect span-to-handler correlation and attribute assignment.
  • Premature termination or loss of spans from the original session.
  • Unreliable tracing data in multi-session environments.

Additionally, cleanupAllPendingSpans(), invoked when a single transport closes, clears the entire global map, prematurely ending and marking as error all spans, including those belonging to other still-active sessions. This is a regression from the previous implementation which used (sessionId, requestId) for correlation, preventing these issues.

packages/core/src/integrations/mcp-server/correlation.ts#L15-L129

import { filterMcpPiiFromSpanData } from './piiFiltering';
import type { RequestId, SessionId } from './types';
// Simplified correlation system that works with or without sessionId
// Maps requestId directly to span data for stateless operation
const requestIdToSpanMap = new Map<
RequestId,
{
span: Span;
method: string;
startTime: number;
}
>();
/**
* Stores span context for later correlation with handler execution
*/
export function storeSpanForRequest(requestId: RequestId, span: Span, method: string): void {
requestIdToSpanMap.set(requestId, {
span,
method,
startTime: Date.now(),
});
}
/**
* Associates handler execution with the corresponding request span
*/
export function associateContextWithRequestSpan<T>(
extraHandlerData: { sessionId?: SessionId; requestId: RequestId } | undefined,
cb: () => T,
): T {
if (extraHandlerData) {
const { requestId } = extraHandlerData;
const spanData = requestIdToSpanMap.get(requestId);
if (!spanData) {
return cb();
}
// Keep span in map for response enrichment (don't delete yet)
return withActiveSpan(spanData.span, () => {
return cb();
});
}
return cb();
}
/**
* Completes span with tool results and cleans up correlation
*/
export function completeSpanWithResults(requestId: RequestId, result: unknown): void {
const spanData = requestIdToSpanMap.get(requestId);
if (spanData) {
const { span, method } = spanData;
const spanWithMethods = span as Span & {
setAttributes: (attrs: Record<string, unknown>) => void;
setStatus: (status: { code: number; message: string }) => void;
end: () => void;
};
if (spanWithMethods.setAttributes && method === 'tools/call') {
// Add tool-specific attributes with PII filtering
const rawToolAttributes = extractToolResultAttributes(result);
const client = getClient();
const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii);
const toolAttributes = filterMcpPiiFromSpanData(rawToolAttributes, sendDefaultPii);
spanWithMethods.setAttributes(toolAttributes);
const isToolError = rawToolAttributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] === true;
if (isToolError) {
spanWithMethods.setStatus({
code: 2, // ERROR
message: 'Tool execution failed',
});
captureError(new Error('Tool returned error result'), 'tool_execution');
}
}
if (spanWithMethods.end) {
spanWithMethods.end();
}
requestIdToSpanMap.delete(requestId);
}
}
/**
* Cleans up all pending spans (for transport close)
*/
export function cleanupAllPendingSpans(): number {
const pendingCount = requestIdToSpanMap.size;
for (const [, spanData] of requestIdToSpanMap) {
const spanWithEnd = spanData.span as Span & {
end: () => void;
setStatus: (status: { code: number; message: string }) => void;
};
if (spanWithEnd.setStatus && spanWithEnd.end) {
spanWithEnd.setStatus({
code: 2, // ERROR
message: 'Transport closed before request completion',
});
spanWithEnd.end();
}
}
requestIdToSpanMap.clear();
return pendingCount;
}

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

MCP Server tracing instrumentation
2 participants