Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
from wrapt import register_post_import_hook, wrap_function_wrapper

from .utils import dont_throw
from .utils import dont_throw, should_send_prompts


class FastMCPInstrumentor:
Expand Down Expand Up @@ -155,9 +155,7 @@ async def traced_method(wrapped, instance, args, kwargs):

def _should_send_prompts(self):
"""Check if content tracing is enabled (matches traceloop SDK)"""
return (
os.getenv("TRACELOOP_TRACE_CONTENT") or "true"
).lower() == "true"
return should_send_prompts()

def _get_json_encoder(self):
"""Get JSON encoder class (simplified - traceloop SDK uses custom JSONEncoder)"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE

from opentelemetry.instrumentation.mcp.version import __version__
from opentelemetry.instrumentation.mcp.utils import dont_throw, Config
from opentelemetry.instrumentation.mcp.utils import (
Config,
dont_throw,
should_send_prompts,
)
from opentelemetry.instrumentation.mcp.fastmcp_instrumentation import (
FastMCPInstrumentor,
)
Expand Down Expand Up @@ -289,8 +293,13 @@ async def _handle_tool_call(self, tracer, method, params, args, kwargs, wrapped)
)
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, entity_name)

# Add input
clean_input = self._extract_clean_input(method, params)
# Add input. Tool arguments are request content, so they are
# recorded only when content capture is enabled.
clean_input = (
self._extract_clean_input(method, params)
if should_send_prompts()
else None
)
if clean_input:
try:
span.set_attribute(
Expand All @@ -308,9 +317,12 @@ async def _handle_tool_call(self, tracer, method, params, args, kwargs, wrapped)
async def _handle_mcp_method(self, tracer, method, args, kwargs, wrapped):
"""Handle non-tool MCP methods with simple serialization"""
with tracer.start_as_current_span(f"{method}.mcp") as span:
span.set_attribute(
SpanAttributes.TRACELOOP_ENTITY_INPUT, f"{serialize(args[0])}"
)
# The serialized request is content: it carries caller-supplied
# params, so it is recorded only when content capture is enabled.
if should_send_prompts():
span.set_attribute(
SpanAttributes.TRACELOOP_ENTITY_INPUT, f"{serialize(args[0])}"
)
return await self._execute_and_handle_result(
span, method, args, kwargs, wrapped, clean_output=False
)
Expand All @@ -321,8 +333,11 @@ async def _execute_and_handle_result(
"""Execute the wrapped function and handle the result"""
try:
result = await wrapped(*args, **kwargs)
# Add output
if clean_output:
# Add output. The response body is content, so it is recorded only
# when content capture is enabled.
if not should_send_prompts():
pass
elif clean_output:
clean_output_data = self._extract_clean_output(method, result)
if clean_output_data:
try:
Expand Down Expand Up @@ -565,9 +580,13 @@ async def send(self, item: Any) -> Any:

with self._tracer.start_as_current_span("ResponseStreamWriter") as span:
if hasattr(request, "result"):
span.set_attribute(
SpanAttributes.MCP_RESPONSE_VALUE, f"{serialize(request.result)}"
)
# The response body is content; the error status below is not,
# so only the value itself is gated.
if should_send_prompts():
span.set_attribute(
SpanAttributes.MCP_RESPONSE_VALUE,
f"{serialize(request.result)}",
)
if "isError" in request.result:
if request.result["isError"] is True:
span.set_status(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@

import asyncio
import logging
import os
import traceback


class Config:
exception_logger = None


def should_send_prompts() -> bool:
"""Whether request/response content may be recorded on spans.

Mirrors the traceloop SDK's ``TRACELOOP_TRACE_CONTENT`` switch: content
capture is on unless an operator explicitly turns it off. Shared by the
FastMCP server wrapper and the MCP client path so a single environment
variable governs both, which is what the package README documents.
"""
return (os.getenv("TRACELOOP_TRACE_CONTENT") or "true").lower() == "true"


def dont_throw(func):
"""
A decorator that wraps the passed in function and logs exceptions instead of throwing them.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""TRACELOOP_TRACE_CONTENT must gate the MCP client path, not only FastMCP.

The package documents TRACELOOP_TRACE_CONTENT as the switch that disables content
logging. Before this test, only the FastMCP server-side wrapper consulted it: the
client path (tools/call arguments, non-tool request bodies, response bodies) recorded
content regardless, so an operator who turned the switch off still got request and
response payloads on their spans.

Each test drives the real client wrapper with a marker value and asserts the marker
is absent from every span attribute when content capture is off, and present when it
is on, so the test fails if either the gate or the capture itself regresses.
"""

import json

from fastmcp import Client, FastMCP

MARKER = "content-capture-marker-9f3a"


def _all_attribute_text(span_exporter) -> str:
"""Every attribute value across every exported span, as one string."""
chunks = []
for span in span_exporter.get_finished_spans():
for value in (span.attributes or {}).values():
if isinstance(value, (list, tuple)):
chunks.extend(str(item) for item in value)
else:
chunks.append(str(value))
return "\n".join(chunks)


def _server() -> FastMCP:
server = FastMCP("content-gate-server")

@server.tool()
async def echo_secret(token: str) -> str:
"""Echo back a caller-supplied token."""
return f"received {token}"

return server


async def test_tool_arguments_suppressed_when_content_capture_off(
span_exporter, monkeypatch
) -> None:
monkeypatch.setenv("TRACELOOP_TRACE_CONTENT", "false")

async with Client(_server()) as client:
await client.call_tool("echo_secret", {"token": MARKER})

assert span_exporter.get_finished_spans(), "expected the tool call to be traced"
assert MARKER not in _all_attribute_text(span_exporter)


async def test_tool_arguments_captured_when_content_capture_on(
span_exporter, monkeypatch
) -> None:
monkeypatch.setenv("TRACELOOP_TRACE_CONTENT", "true")

async with Client(_server()) as client:
await client.call_tool("echo_secret", {"token": MARKER})

# The gate must not silently disable capture altogether: with the switch on,
# the argument is still recorded.
assert MARKER in _all_attribute_text(span_exporter)


async def test_non_tool_response_body_suppressed_when_content_capture_off(
span_exporter, monkeypatch
) -> None:
"""list_tools goes through _handle_mcp_method, which serialized the whole response.

The marker lives in the registered tool description, so it travels back in the
list_tools result and exercises the response-serialization path.
"""
monkeypatch.setenv("TRACELOOP_TRACE_CONTENT", "false")

server = FastMCP("content-gate-server")

@server.tool(description=f"A tool whose description carries {MARKER}.")
async def documented(arg: str) -> str:
return arg

async with Client(server) as client:
tools = await client.list_tools()

assert any(MARKER in (t.description or "") for t in tools), (
"the marker must reach the client, otherwise this test proves nothing"
)
assert span_exporter.get_finished_spans(), "expected the request to be traced"
assert MARKER not in _all_attribute_text(span_exporter)


async def test_span_structure_survives_content_capture_off(
span_exporter, monkeypatch
) -> None:
"""Turning content off must not remove spans or their non-content attributes."""
monkeypatch.setenv("TRACELOOP_TRACE_CONTENT", "false")

async with Client(_server()) as client:
await client.call_tool("echo_secret", {"token": MARKER})

spans = span_exporter.get_finished_spans()
tool_spans = [s for s in spans if s.name.endswith(".tool")]
assert tool_spans, f"expected a tool span, got {[s.name for s in spans]}"

entity_names = [
(s.attributes or {}).get("traceloop.entity.name") for s in tool_spans
]
assert "echo_secret" in entity_names

# Structural attributes stay; only content is withheld.
for span in tool_spans:
attributes = span.attributes or {}
assert "traceloop.span.kind" in attributes
for key in ("traceloop.entity.input", "traceloop.entity.output"):
if key in attributes:
json.loads(attributes[key]) # if present it must still be valid JSON
assert MARKER not in attributes[key]