60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
153 lines
5.5 KiB
Python
153 lines
5.5 KiB
Python
"""Tracing coverage for sampling create_message and tool-execution spans.
|
|
|
|
Regression focus: the `sampling create_message` span is created with
|
|
`record_exception=False, set_status_on_exception=False` and records the
|
|
exception manually in its `except` block. A failed sampling call must
|
|
therefore produce exactly ONE exception event, not two.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from mcp_types import TextContent
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
from opentelemetry.trace import StatusCode
|
|
|
|
from fastmcp import Client, Context, FastMCP
|
|
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
|
|
|
|
|
|
def _spans_named(exporter: InMemorySpanExporter, name: str):
|
|
return [s for s in exporter.get_finished_spans() if s.name == name]
|
|
|
|
|
|
def _exception_events(span):
|
|
return [e for e in span.events if e.name == "exception"]
|
|
|
|
|
|
class TestSamplingCreateMessageSpan:
|
|
async def test_success_creates_span_with_attributes(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
def sampling_handler(
|
|
messages: list[SamplingMessage],
|
|
params: SamplingParams,
|
|
ctx: RequestContext,
|
|
) -> str:
|
|
return "sampled-text"
|
|
|
|
mcp = FastMCP("sampling-server")
|
|
|
|
@mcp.tool
|
|
async def ask(question: str, context: Context) -> str:
|
|
result = await context.sample(messages=question)
|
|
return result.text or ""
|
|
|
|
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
|
await client.call_tool("ask", {"question": "hi"})
|
|
|
|
spans = _spans_named(trace_exporter, "sampling create_message")
|
|
assert len(spans) == 1
|
|
span = spans[0]
|
|
assert span.attributes is not None
|
|
assert span.attributes["mcp.method.name"] == "sampling/createMessage"
|
|
assert span.attributes["fastmcp.server.name"] == "sampling-server"
|
|
# Success path must not record any exception.
|
|
assert _exception_events(span) == []
|
|
assert span.status.status_code != StatusCode.ERROR
|
|
|
|
async def test_failure_records_exception_exactly_once(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
"""Regression: span created with record_exception=False so the manual
|
|
record_exception in the except block fires exactly once (no duplicate
|
|
exception events from OTel auto-recording on `with` exit)."""
|
|
|
|
def sampling_handler(
|
|
messages: list[SamplingMessage],
|
|
params: SamplingParams,
|
|
ctx: RequestContext,
|
|
) -> str:
|
|
raise RuntimeError("sampling boom")
|
|
|
|
mcp = FastMCP("sampling-server")
|
|
|
|
@mcp.tool
|
|
async def ask(question: str, context: Context) -> str:
|
|
result = await context.sample(messages=question)
|
|
return result.text or ""
|
|
|
|
with pytest.raises(Exception):
|
|
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
|
await client.call_tool("ask", {"question": "hi"})
|
|
|
|
spans = _spans_named(trace_exporter, "sampling create_message")
|
|
assert len(spans) == 1
|
|
span = spans[0]
|
|
assert span.status.status_code == StatusCode.ERROR
|
|
assert span.attributes is not None
|
|
assert "error.type" in span.attributes
|
|
# The whole point of the fix: exactly one exception event.
|
|
assert len(_exception_events(span)) == 1
|
|
|
|
|
|
class TestSamplingToolSpan:
|
|
async def test_tool_error_span_records_exception_once(
|
|
self, trace_exporter: InMemorySpanExporter
|
|
):
|
|
from mcp_types import CreateMessageResultWithTools, ToolUseContent
|
|
|
|
call_count = 0
|
|
|
|
def boom_tool() -> str:
|
|
raise ValueError("tool exploded")
|
|
|
|
def sampling_handler(
|
|
messages: list[SamplingMessage],
|
|
params: SamplingParams,
|
|
ctx: RequestContext,
|
|
) -> CreateMessageResultWithTools:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return CreateMessageResultWithTools(
|
|
role="assistant",
|
|
content=[
|
|
ToolUseContent(
|
|
type="tool_use",
|
|
id="call_1",
|
|
name="boom_tool",
|
|
input={},
|
|
)
|
|
],
|
|
model="test-model",
|
|
stop_reason="toolUse",
|
|
)
|
|
return CreateMessageResultWithTools(
|
|
role="assistant",
|
|
content=[TextContent(type="text", text="done")],
|
|
model="test-model",
|
|
stop_reason="endTurn",
|
|
)
|
|
|
|
mcp = FastMCP(sampling_handler=sampling_handler)
|
|
|
|
@mcp.tool
|
|
async def driver(context: Context) -> str:
|
|
result = await context.sample(messages="go", tools=[boom_tool])
|
|
return result.text or ""
|
|
|
|
async with Client(mcp) as client:
|
|
await client.call_tool("driver", {})
|
|
|
|
spans = _spans_named(trace_exporter, "sampling tool boom_tool")
|
|
assert len(spans) == 1
|
|
span = spans[0]
|
|
assert span.status.status_code == StatusCode.ERROR
|
|
assert span.attributes is not None
|
|
assert span.attributes["gen_ai.tool.name"] == "boom_tool"
|
|
assert "error.type" in span.attributes
|
|
# Tool spans catch-and-convert (no re-raise), so OTel auto-recording
|
|
# never fires; the manual record_exception must fire exactly once.
|
|
assert len(_exception_events(span)) == 1
|