55import os
66import sys
77
8- import httpx
8+ import asyncio
9+ import functools
10+
911import requests
1012import uvicorn
1113from fastmcp import FastMCP
1214from requests .adapters import HTTPAdapter
1315from starlette .middleware import Middleware
1416from starlette .middleware .base import BaseHTTPMiddleware
1517from urllib3 .util .retry import Retry
16- from fastmcp .server .dependencies import get_http_headers
1718
1819from opentelemetry import trace , context as otel_context
1920from 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-
6664def 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 })
10092async 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