Skip to content

Commit b301d4a

Browse files
committed
♻️ Avoid duplicate tool call spans
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
1 parent a893e09 commit b301d4a

3 files changed

Lines changed: 56 additions & 66 deletions

File tree

mcp/weather_tool/pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ description = "simple weather tool"
55
readme = "README.md"
66
requires-python = ">=3.10"
77
dependencies = [
8-
"httpx>=0.28.1",
98
"requests>=2.32.3",
109
"authlib>=1.6.9", # Indirect; prevents CVE-2026-27962
1110
"urllib3>=2.6.3", # Indirect; prevents CVE-2025-66418

mcp/weather_tool/uv.lock

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mcp/weather_tool/weather_tool.py

Lines changed: 56 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
import os
66
import sys
77

8-
import httpx
8+
import asyncio
9+
import functools
10+
911
import requests
1012
import uvicorn
1113
from fastmcp import FastMCP
1214
from requests.adapters import HTTPAdapter
1315
from starlette.middleware import Middleware
1416
from starlette.middleware.base import BaseHTTPMiddleware
1517
from urllib3.util.retry import Retry
16-
from fastmcp.server.dependencies import get_http_headers
1718

1819
from opentelemetry import trace, context as otel_context
1920
from opentelemetry.sdk.trace import TracerProvider
@@ -60,9 +61,6 @@ def _build_resilient_session() -> requests.Session:
6061
_session = _build_resilient_session()
6162

6263

63-
_tracer: trace.Tracer | None = None
64-
65-
6664
def setup_tracing() -> None:
6765
"""Initialize OpenTelemetry tracing with W3C trace context propagation."""
6866
otlp_endpoint = os.getenv(
@@ -89,73 +87,68 @@ def setup_tracing() -> None:
8987
logger.info(f"Tracing initialized: service={service_name} otlp={otlp_endpoint}")
9088

9189

92-
def get_tracer() -> trace.Tracer:
93-
global _tracer
94-
if _tracer is None:
95-
_tracer = trace.get_tracer("weather-mcp-tool")
96-
return _tracer
97-
9890

9991
@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True})
10092
async def get_weather(city: str) -> str:
10193
"""Get weather info for a city"""
10294
# Extract W3C traceparent from the incoming MCP HTTP request so this tool's
10395
# span becomes a child of the MCP gateway span (which is itself a child of
10496
# the agent span), giving a full agent → gateway → tool trace chain.
105-
headers = get_http_headers()
106-
incoming_ctx = extract(headers)
107-
token = otel_context.attach(incoming_ctx)
97+
# Enrich FastMCP's own span with gen_ai attributes rather than creating a
98+
# child span — FastMCP already creates a tools/call span, so a second one
99+
# with the same name is misleading. Adding to the current span merges both
100+
# sets of attributes into a single, complete span.
101+
span = trace.get_current_span()
102+
span.set_attribute("gen_ai.operation.name", "execute_tool")
103+
span.set_attribute("gen_ai.tool.name", "get_weather")
104+
span.set_attribute("gen_ai.tool.call.arguments", json.dumps({"city": city}))
105+
106+
logger.debug(f"Getting weather info for city '{city}'.")
107+
108+
loop = asyncio.get_event_loop()
108109

109-
# Span name follows the MCP semconv format: "{mcp.method.name} {gen_ai.tool.name}"
110110
try:
111-
with get_tracer().start_as_current_span("tools/call get_weather", context=incoming_ctx) as span:
112-
span.set_attribute("mcp.method.name", "tools/call")
113-
span.set_attribute("gen_ai.operation.name", "execute_tool")
114-
span.set_attribute("gen_ai.tool.name", "get_weather")
115-
span.set_attribute("gen_ai.tool.call.arguments", json.dumps({"city": city}))
116-
117-
logger.debug(f"Getting weather info for city '{city}'.")
118-
119-
try:
120-
base_url = "https://geocoding-api.open-meteo.com/v1/search"
121-
async with httpx.AsyncClient() as client:
122-
response = await client.get(base_url, params={"name": city, "count": 1}, timeout=10)
123-
response.raise_for_status()
124-
data = response.json()
125-
126-
if not data or "results" not in data:
127-
result = f"City {city} not found"
128-
span.set_attribute("error.type", "tool_error")
129-
span.set_status(Status(StatusCode.ERROR, result))
130-
return result
131-
132-
latitude = data["results"][0]["latitude"]
133-
longitude = data["results"][0]["longitude"]
134-
135-
weather_url = "https://api.open-meteo.com/v1/forecast"
136-
weather_params = {
137-
"latitude": latitude,
138-
"longitude": longitude,
139-
"temperature_unit": "fahrenheit",
140-
"current_weather": True,
141-
}
142-
async with httpx.AsyncClient() as client:
143-
weather_response = await client.get(weather_url, params=weather_params, timeout=10)
144-
weather_response.raise_for_status()
145-
weather_data = weather_response.json()
146-
147-
result = json.dumps(weather_data["current_weather"])
148-
span.set_attribute("gen_ai.tool.call.result", result)
149-
span.set_status(Status(StatusCode.OK))
150-
return result
151-
152-
except Exception as e:
153-
span.set_attribute("error.type", type(e).__name__)
154-
span.set_status(Status(StatusCode.ERROR, str(e)))
155-
span.record_exception(e)
156-
raise
157-
finally:
158-
otel_context.detach(token)
111+
base_url = "https://geocoding-api.open-meteo.com/v1/search"
112+
response = await loop.run_in_executor(
113+
None,
114+
functools.partial(_session.get, base_url, params={"name": city, "count": 1}, timeout=_REQUEST_TIMEOUT),
115+
)
116+
response.raise_for_status()
117+
data = response.json()
118+
119+
if not data or "results" not in data:
120+
result = f"City {city} not found"
121+
span.set_attribute("error.type", "tool_error")
122+
span.set_status(Status(StatusCode.ERROR, result))
123+
return result
124+
125+
latitude = data["results"][0]["latitude"]
126+
longitude = data["results"][0]["longitude"]
127+
128+
weather_url = "https://api.open-meteo.com/v1/forecast"
129+
weather_params = {
130+
"latitude": latitude,
131+
"longitude": longitude,
132+
"temperature_unit": "fahrenheit",
133+
"current_weather": True,
134+
}
135+
weather_response = await loop.run_in_executor(
136+
None,
137+
functools.partial(_session.get, weather_url, params=weather_params, timeout=_REQUEST_TIMEOUT),
138+
)
139+
weather_response.raise_for_status()
140+
weather_data = weather_response.json()
141+
142+
result = json.dumps(weather_data["current_weather"])
143+
span.set_attribute("gen_ai.tool.call.result", result)
144+
span.set_status(Status(StatusCode.OK))
145+
return result
146+
147+
except Exception as e:
148+
span.set_attribute("error.type", type(e).__name__)
149+
span.set_status(Status(StatusCode.ERROR, str(e)))
150+
span.record_exception(e)
151+
raise
159152

160153

161154

0 commit comments

Comments
 (0)