Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class FastMCPInstrumentor:

def __init__(self):
self._tracer = None
self._server_name = None

def instrument(self, tracer: Tracer):
"""Apply FastMCP-specific instrumentation."""
Expand All @@ -30,12 +31,35 @@ def instrument(self, tracer: Tracer):
"fastmcp.tools.tool_manager",
)

# Instrument FastMCP __init__ to capture server name
register_post_import_hook(
lambda _: wrap_function_wrapper(
"fastmcp", "FastMCP.__init__", self._fastmcp_init_wrapper()
),
"fastmcp",
)

def uninstrument(self):
"""Remove FastMCP-specific instrumentation."""
# Note: wrapt doesn't provide a clean way to unwrap post-import hooks
# This is a limitation we'll need to document
pass

def _fastmcp_init_wrapper(self):
"""Create wrapper for FastMCP initialization to capture server name."""
@dont_throw
def traced_method(wrapped, instance, args, kwargs):
# Call the original __init__ first
result = wrapped(*args, **kwargs)

if args and len(args) > 0:
self._server_name = f"{args[0]}.mcp"
elif 'name' in kwargs:
self._server_name = f"{kwargs['name']}.mcp"

return result
return traced_method
Comment on lines +48 to +61
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Don’t wrap FastMCP.init with dont_throw.

Swallowing exceptions from __init__ can instantiate a half-initialized server, breaking invariants. Remove the decorator and keep simple name capture.

-    def _fastmcp_init_wrapper(self):
+    def _fastmcp_init_wrapper(self):
         """Create wrapper for FastMCP initialization to capture server name."""
-        @dont_throw
         def traced_method(wrapped, instance, args, kwargs):
             # Call the original __init__ first
             result = wrapped(*args, **kwargs)
@@
             return result
         return traced_method
📝 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
def _fastmcp_init_wrapper(self):
"""Create wrapper for FastMCP initialization to capture server name."""
@dont_throw
def traced_method(wrapped, instance, args, kwargs):
# Call the original __init__ first
result = wrapped(*args, **kwargs)
if args and len(args) > 0:
self._server_name = f"{args[0]}.mcp"
elif 'name' in kwargs:
self._server_name = f"{kwargs['name']}.mcp"
return result
return traced_method
def _fastmcp_init_wrapper(self):
"""Create wrapper for FastMCP initialization to capture server name."""
def traced_method(wrapped, instance, args, kwargs):
# Call the original __init__ first
result = wrapped(*args, **kwargs)
if args and len(args) > 0:
self._server_name = f"{args[0]}.mcp"
elif 'name' in kwargs:
self._server_name = f"{kwargs['name']}.mcp"
return result
return traced_method
🧰 Tools
🪛 Ruff (0.13.3)

51-51: Unused function argument: instance

(ARG001)


def _fastmcp_tool_wrapper(self):
"""Create wrapper for FastMCP tool execution."""
@dont_throw
Expand All @@ -62,12 +86,21 @@ async def traced_method(wrapped, instance, args, kwargs):
with self._tracer.start_as_current_span("mcp.server") as mcp_span:
mcp_span.set_attribute(SpanAttributes.TRACELOOP_SPAN_KIND, "server")
mcp_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, "mcp.server")
if self._server_name:
mcp_span.set_attribute(SpanAttributes.TRACELOOP_WORKFLOW_NAME, self._server_name)

# Add MCP_REQUEST_ID to the parent span
import time
request_id = str(int(time.time() * 1000)) # milliseconds
mcp_span.set_attribute(SpanAttributes.MCP_REQUEST_ID, request_id)

# Create nested tool span
span_name = f"{entity_name}.tool"
with self._tracer.start_as_current_span(span_name) as tool_span:
tool_span.set_attribute(SpanAttributes.TRACELOOP_SPAN_KIND, TraceloopSpanKindValues.TOOL.value)
tool_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, entity_name)
if self._server_name:
tool_span.set_attribute(SpanAttributes.TRACELOOP_WORKFLOW_NAME, self._server_name)

if self._should_send_prompts():
try:
Expand All @@ -84,27 +117,47 @@ async def traced_method(wrapped, instance, args, kwargs):
try:
result = await wrapped(*args, **kwargs)

# Add output in traceloop format to tool span
if self._should_send_prompts() and result:
# 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 FastMCP ToolResult object
if hasattr(result, 'content') and result.content:
# Convert FastMCP Content objects to serializable format
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))
# Fallback to string representation
truncated_output = str(result)

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
# 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)

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))

tool_span.set_status(Status(StatusCode.OK))
mcp_span.set_status(Status(StatusCode.OK))
Expand Down
30 changes: 28 additions & 2 deletions packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ def get_greeting() -> str:

# Test tool calling
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
assert len(result) == 1
assert result[0].text == "8"
assert len(result.content) == 1
assert result.content[0].text == "8"

# Test resource listing
resources_res = await client.list_resources()
Expand Down Expand Up @@ -121,3 +121,29 @@ def get_greeting() -> str:
assert len(request_writer_spans) == 0, (
f"RequestStreamWriter spans should be removed, found {len(request_writer_spans)}"
)

# Verify TRACELOOP_WORKFLOW_NAME is set correctly on server spans
mcp_server_spans = [span for span in spans if span.name == 'mcp.server']
assert len(mcp_server_spans) >= 1, (
f"Expected at least 1 mcp.server span, found {len(mcp_server_spans)}"
)

for server_span in mcp_server_spans:
workflow_name = server_span.attributes.get('traceloop.workflow.name')
assert workflow_name == 'test-server.mcp', (
f"Expected workflow name 'test-server.mcp', got '{workflow_name}'"
)

# Verify TRACELOOP_WORKFLOW_NAME is also set on tool spans
server_tool_spans = [span for span in spans if span.name == 'add_numbers.tool'
and span.attributes.get('traceloop.span.kind') == 'tool'
and 'traceloop.workflow.name' in span.attributes]
assert len(server_tool_spans) >= 1, (
f"Expected at least 1 server-side tool span with workflow name, found {len(server_tool_spans)}"
)

for tool_span in server_tool_spans:
workflow_name = tool_span.attributes.get('traceloop.workflow.name')
assert workflow_name == 'test-server.mcp', (
f"Expected workflow name 'test-server.mcp' on tool span, got '{workflow_name}'"
)
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def get_test_config() -> dict:
# Test 2: Verify traceloop attributes
assert tool_span.attributes.get("traceloop.span.kind") == "tool"
assert tool_span.attributes.get("traceloop.entity.name") == "process_data"
assert tool_span.attributes.get("traceloop.workflow.name") == "attribute-test-server.mcp"

# Test 3: Verify span status
assert tool_span.status.status_code.name == "OK"
Expand Down Expand Up @@ -166,4 +167,7 @@ async def failing_tool(should_fail: bool = True) -> str:
assert error_span.attributes.get("traceloop.span.kind") == "tool"
assert error_span.attributes.get("traceloop.entity.name") == "failing_tool"

# Verify workflow name is set correctly even on error spans
assert error_span.attributes.get("traceloop.workflow.name") == "error-test-server.mcp"

print("✅ Error handling validated")
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ async def test_tool(x: int) -> int:
async with Client(server) as client:
# Test tool calling
result = await client.call_tool("test_tool", {"x": 5})
assert len(result) == 1
assert result[0].text == "10"
assert len(result.content) == 1
assert result.content[0].text == "10"

# Get the finished spans
spans = span_exporter.get_finished_spans()
Expand Down