-
Notifications
You must be signed in to change notification settings - Fork 807
feat(mcp): MCP response Span Capture for Stdio Mode #3413
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
base: main
Are you sure you want to change the base?
Conversation
WalkthroughUpdates 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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this 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.
📒 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
# 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) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
# 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.
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)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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): |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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
feat(instrumentation): ...
orfix(instrumentation): ...
.Important
Enhance
FastMCPInstrumentor
to capture server names and handle various MCP response types, with updated tests for verification.FastMCPInstrumentor
duringFastMCP.__init__
._fastmcp_tool_wrapper()
to ensure MCP responses are captured and serialized correctly.mcp_span
and optionally totool_span
if content tracing is enabled.test_fastmcp_instrumentor
to verify span attributes for server name and response content.RequestStreamWriter
spans are not present in the test results.This description was created by
for 039f5d6. You can customize this summary. It will automatically update as commits are pushed.
Summary by CodeRabbit
New Features
Bug Fixes
Tests