Skip to content

Conversation

elinacse
Copy link
Contributor

@elinacse elinacse commented Oct 15, 2025

  • I have added tests that cover my changes.
  • If adding a new instrumentation or changing an existing one, I've added screenshots from some observability platform showing the change.
  • PR name follows conventional commits format: feat(instrumentation): ... or fix(instrumentation): ....
  • (If applicable) I have updated the documentation accordingly.

Important

Enhance FastMCPInstrumentor to capture server names and handle various MCP response types, with updated tests for verification.

  • Behavior:
    • Capture server name in FastMCPInstrumentor during FastMCP.__init__.
    • Handle different result types in _fastmcp_tool_wrapper() to ensure MCP responses are captured and serialized correctly.
    • Always add MCP response to mcp_span and optionally to tool_span if content tracing is enabled.
  • Tests:
    • Update test_fastmcp_instrumentor to verify span attributes for server name and response content.
    • Ensure RequestStreamWriter spans are not present in the test results.

This description was created by Ellipsis for 039f5d6. You can customize this summary. It will automatically update as commits are pushed.

Summary by CodeRabbit

  • New Features

    • MCP spans now always include a truncated response value, with broader support for different result types and safe fallbacks.
    • Tool span output now respects content tracing settings and is emitted only when enabled.
  • Bug Fixes

    • Improved resilience to serialization issues, preventing missing outputs and ensuring consistent span status updates.
  • Tests

    • Updated assertions to accommodate optional tool output when content tracing is disabled, verifying output only when present.

Copy link

coderabbitai bot commented Oct 15, 2025

Walkthrough

Updates MCP instrumentation to always record MCP response values by serializing diverse result shapes and truncating as needed. Tool span output is now conditional on content-tracing configuration. Tests are adjusted to assert tool output only when present.

Changes

Cohort / File(s) Summary
MCP instrumentation logic
packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/fastmcp_instrumentation.py
Unconditional MCP response logging with robust serialization (lists, content objects, dict-like, fallback to string), truncation, error-safe fallbacks; conditional tool span output based on content tracing; consistent span status updates.
Tests
packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py
Test updated to conditionally assert traceloop.entity.output only when present, reflecting optional tool output emission.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant Tool
  participant Instrumentation as FastMCP Instrumentation
  participant ToolSpan as Tool Span
  participant MCPSpan as MCP Span

  Client->>Tool: Invoke tool()
  Tool->>Instrumentation: Return result (various shapes)
  rect rgba(200, 230, 255, 0.3)
    note over Instrumentation: Serialize result (list/content/dict-like/string), truncate
    Instrumentation->>MCPSpan: setAttribute(MCP_RESPONSE_VALUE, truncated_output)
  end

  alt Content tracing enabled
    Instrumentation->>ToolSpan: setAttribute(TRACELOOP_ENTITY_OUTPUT, truncated_output)
  else Disabled
    note over ToolSpan: Output attribute not set
  end

  Instrumentation->>ToolSpan: setStatus/finish
  Instrumentation->>MCPSpan: setStatus/finish

  opt Serialization error
    note over Instrumentation: Fallback to string logging for MCP response
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • nina-kollman

Poem

In cables and spans I twitch my nose,
Logging replies wherever it goes.
Tool tales whisper—sometimes they hide—
While MCP speaks, ears open wide.
JSON crumbs, neatly nibbled tight,
A rabbit approves: traced just right. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title uses conventional commit formatting and succinctly describes the new feature of capturing MCP response spans in stdio mode, which aligns with the core changes to always log and record MCP output in the span.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e66894f and 1585dec.

📒 Files selected for processing (2)
  • packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/fastmcp_instrumentation.py (1 hunks)
  • packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Store API keys only in environment variables/secure vaults; never hardcode secrets in code
Use Flake8 for code linting and adhere to its rules

Files:

  • packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py
  • packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/fastmcp_instrumentation.py
🧬 Code graph analysis (1)
packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/fastmcp_instrumentation.py (2)
packages/traceloop-sdk/traceloop/sdk/decorators/base.py (2)
  • _truncate_json_if_needed (38-51)
  • _should_send_prompts (127-130)
packages/opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.py (1)
  • SpanAttributes (64-261)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Test Packages (3.11)
  • GitHub Check: Test Packages (3.12)
  • GitHub Check: Test Packages (3.10)
  • GitHub Check: Build Packages (3.11)
  • GitHub Check: Lint

Comment on lines +115 to +165
# Always add response to MCP span regardless of content tracing setting
if result:
try:
# Convert FastMCP Content objects to serializable format
output_data = []
for item in result:
if hasattr(item, 'text'):
output_data.append({"type": "text", "content": item.text})
elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
# Handle different result types
if isinstance(result, list):
# FastMCP returns list of Content objects directly
output_data = []
for item in result:
if hasattr(item, 'text'):
output_data.append({"type": "text", "content": item.text})
elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
else:
output_data.append(str(item))

json_output = json.dumps(output_data, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
elif hasattr(result, 'content') and result.content:
# Handle FastMCP ToolResult object with .content attribute
output_data = []
for item in result.content:
if hasattr(item, 'text'):
output_data.append({"type": "text", "content": item.text})
elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
else:
output_data.append(str(item))

json_output = json.dumps(output_data, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
else:
# Handle other result types
if hasattr(result, '__dict__'):
# Convert object to dict
result_dict = {}
for key, value in result.__dict__.items():
if not key.startswith('_'):
result_dict[key] = str(value)
json_output = json.dumps(result_dict, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
else:
output_data.append(str(item))

json_output = json.dumps(output_data, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
tool_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT, truncated_output)

# Also add response to MCP span
# Fallback to string representation
truncated_output = str(result)

# Add response to MCP span
mcp_span.set_attribute(SpanAttributes.MCP_RESPONSE_VALUE, truncated_output)

# Also add to tool span if content tracing is enabled
if self._should_send_prompts():
tool_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT, truncated_output)

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Capture falsey MCP results as well

if result: on Line 116 skips legitimate falsey outputs such as 0, "", or an empty list, so those responses never reach SpanAttributes.MCP_RESPONSE_VALUE. Switch the guard to if result is not None: (or remove it entirely and handle None explicitly) so every real response is captured.

-                        if result:
+                        if result is not None:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Always add response to MCP span regardless of content tracing setting
if result:
try:
# Convert FastMCP Content objects to serializable format
output_data = []
for item in result:
if hasattr(item, 'text'):
output_data.append({"type": "text", "content": item.text})
elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
# Handle different result types
if isinstance(result, list):
# FastMCP returns list of Content objects directly
output_data = []
for item in result:
if hasattr(item, 'text'):
output_data.append({"type": "text", "content": item.text})
elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
else:
output_data.append(str(item))
json_output = json.dumps(output_data, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
elif hasattr(result, 'content') and result.content:
# Handle FastMCP ToolResult object with .content attribute
output_data = []
for item in result.content:
if hasattr(item, 'text'):
output_data.append({"type": "text", "content": item.text})
elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
else:
output_data.append(str(item))
json_output = json.dumps(output_data, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
else:
# Handle other result types
if hasattr(result, '__dict__'):
# Convert object to dict
result_dict = {}
for key, value in result.__dict__.items():
if not key.startswith('_'):
result_dict[key] = str(value)
json_output = json.dumps(result_dict, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
else:
output_data.append(str(item))
json_output = json.dumps(output_data, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
tool_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT, truncated_output)
# Also add response to MCP span
# Fallback to string representation
truncated_output = str(result)
# Add response to MCP span
mcp_span.set_attribute(SpanAttributes.MCP_RESPONSE_VALUE, truncated_output)
# Also add to tool span if content tracing is enabled
if self._should_send_prompts():
tool_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT, truncated_output)
# Always add response to MCP span regardless of content tracing setting
if result is not None:
try:
# Handle different result types
if isinstance(result, list):
# FastMCP returns list of Content objects directly
output_data = []
for item in result:
if hasattr(item, 'text'):
output_data.append({"type": "text", "content": item.text})
elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
else:
output_data.append(str(item))
json_output = json.dumps(output_data, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
elif hasattr(result, 'content') and result.content:
# Handle FastMCP ToolResult object with .content attribute
output_data = []
for item in result.content:
if hasattr(item, 'text'):
output_data.append({"type": "text", "content": item.text})
elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
else:
output_data.append(str(item))
json_output = json.dumps(output_data, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
else:
# Handle other result types
if hasattr(result, '__dict__'):
# Convert object to dict
result_dict = {}
for key, value in result.__dict__.items():
if not key.startswith('_'):
result_dict[key] = str(value)
json_output = json.dumps(result_dict, cls=self._get_json_encoder())
truncated_output = self._truncate_json_if_needed(json_output)
else:
# Fallback to string representation
truncated_output = str(result)
# Add response to MCP span
mcp_span.set_attribute(SpanAttributes.MCP_RESPONSE_VALUE, truncated_output)
# Also add to tool span if content tracing is enabled
if self._should_send_prompts():
tool_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT, truncated_output)
🤖 Prompt for AI Agents
In
packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/fastmcp_instrumentation.py
around lines 115 to 165, the current guard uses "if result:" which drops
legitimate falsey responses like 0, "", or [] and prevents them from being added
to SpanAttributes.MCP_RESPONSE_VALUE; change the check to "if result is not
None:" (or remove the truthiness check and explicitly handle the None case) so
falsey but valid outputs are processed, ensure the existing branches handle
empty lists/strings correctly and None is handled separately (e.g., set
truncated_output to "null" or skip setting attributes when result is None) and
keep the rest of the JSON conversion, truncation, and attribute-setting logic
unchanged.

Comment on lines 166 to +168
except (TypeError, ValueError):
pass # Skip output logging if serialization fails
# Fallback: add raw result as string
mcp_span.set_attribute(SpanAttributes.MCP_RESPONSE_VALUE, str(result))
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Apply truncation in the fallback path

When serialization fails (Lines 166-168) we write str(result) directly to MCP_RESPONSE_VALUE, bypassing _truncate_json_if_needed. Large payloads can exceed OTEL span attribute limits and be dropped. Please run the string through _truncate_json_if_needed before setting the attribute.

-                                mcp_span.set_attribute(SpanAttributes.MCP_RESPONSE_VALUE, str(result))
+                                truncated_output = self._truncate_json_if_needed(str(result))
+                                mcp_span.set_attribute(SpanAttributes.MCP_RESPONSE_VALUE, truncated_output)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except (TypeError, ValueError):
pass # Skip output logging if serialization fails
# Fallback: add raw result as string
mcp_span.set_attribute(SpanAttributes.MCP_RESPONSE_VALUE, str(result))
except (TypeError, ValueError):
# Fallback: add raw result as string
truncated_output = self._truncate_json_if_needed(str(result))
mcp_span.set_attribute(SpanAttributes.MCP_RESPONSE_VALUE, truncated_output)
🤖 Prompt for AI Agents
In
packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/fastmcp_instrumentation.py
around lines 166 to 168, the fallback path writes str(result) directly to the
MCP_RESPONSE_VALUE span attribute which can exceed OTEL attribute size limits;
instead pass the stringified result through the existing
_truncate_json_if_needed helper and use its return value when calling
mcp_span.set_attribute to ensure large payloads are truncated before being set.

elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
# Handle different result types
if isinstance(result, list):
Copy link
Member

Choose a reason for hiding this comment

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

why not just serialize the result as json?

elif hasattr(item, '__dict__'):
output_data.append(item.__dict__)
# Handle different result types
if isinstance(result, list):
Copy link
Member

Choose a reason for hiding this comment

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

this whole logic should be extracted to a util

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.

2 participants