golf-mcp 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of golf-mcp might be problematic. Click here for more details.

@@ -35,30 +35,44 @@ The server is configured in `pre_build.py` to extract GitHub tokens from the `Au
35
35
  ```python
36
36
  configure_api_key(
37
37
  header_name="Authorization",
38
- header_prefix="Bearer "
38
+ header_prefix="Bearer ",
39
+ required=True # Reject requests without a valid API key
39
40
  )
40
41
  ```
41
42
 
42
- This configuration handles GitHub's token format: `Authorization: Bearer ghp_xxxxxxxxxxxx`
43
+ This configuration:
44
+ - Handles GitHub's token format: `Authorization: Bearer ghp_xxxxxxxxxxxx`
45
+ - **Enforces authentication**: When `required=True` (default), requests without a valid API key will be rejected with a 401 Unauthorized error
46
+ - For optional authentication (pass-through mode), set `required=False`
43
47
 
44
48
  ## How It Works
45
49
 
46
50
  1. **Client sends request** with GitHub token in the Authorization header
47
- 2. **Golf middleware** extracts the token based on your configuration
48
- 3. **Tools retrieve token** using `get_api_key()`
49
- 4. **Token is forwarded** to GitHub API in the appropriate format
50
- 5. **GitHub validates** the token and returns results
51
+ 2. **Golf middleware** checks if API key is required and present
52
+ 3. **If required and missing**, the request is rejected with 401 Unauthorized
53
+ 4. **If present**, the token is extracted based on your configuration
54
+ 5. **Tools retrieve token** using `get_api_key()`
55
+ 6. **Token is forwarded** to GitHub API in the appropriate format
56
+ 7. **GitHub validates** the token and returns results
51
57
 
52
58
  ## Running the Server
53
59
 
54
60
  1. Build and run:
55
61
  ```bash
56
- golf build dev
62
+ golf build
57
63
  golf run
58
64
  ```
59
65
 
60
66
  2. The server will start on `http://127.0.0.1:3000` (configurable in `golf.json`)
61
67
 
68
+ 3. Test authentication enforcement:
69
+ ```bash
70
+ # This will fail with 401 Unauthorized
71
+ curl http://localhost:3000/mcp
72
+
73
+ # This will succeed
74
+ curl -H "Authorization: Bearer ghp_your_token_here" http://localhost:3000/mcp
75
+ ```
62
76
 
63
77
  ## GitHub Token Permissions
64
78
 
@@ -3,5 +3,7 @@
3
3
  "description": "MCP server for GitHub API operations with API key authentication",
4
4
  "host": "127.0.0.1",
5
5
  "port": 3000,
6
- "transport": "sse"
6
+ "transport": "sse",
7
+ "opentelemetry_enabled": true,
8
+ "opentelemetry_default_exporter": "otlp_http"
7
9
  }
@@ -6,5 +6,6 @@ from golf.auth import configure_api_key
6
6
  # GitHub expects: Authorization: Bearer ghp_xxxx or Authorization: token ghp_xxxx
7
7
  configure_api_key(
8
8
  header_name="Authorization",
9
- header_prefix="Bearer " # Will handle both "Bearer " and "token " prefixes
9
+ header_prefix="Bearer ", # Will handle both "Bearer " and "token " prefixes
10
+ required=True # Reject requests without a valid API key
10
11
  )
golf/examples/basic/.env CHANGED
@@ -1,3 +1,5 @@
1
1
  GITHUB_CLIENT_ID="Ov23liPVrFkEzGhXro5A"
2
2
  GITHUB_CLIENT_SECRET="4f050336d569559705963d88cf8ec8b3ce10441a"
3
- JWT_SECRET="example-jwt-secret-for-development-only"
3
+ JWT_SECRET="example-jwt-secret-for-development-only"
4
+ OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces"
5
+ OTEL_SERVICE_NAME="golf-mcp"
@@ -1,3 +1,5 @@
1
1
  GOLF_CLIENT_ID="default-client-id"
2
2
  GOLF_CLIENT_SECRET="default-secret"
3
- JWT_SECRET="example-jwt-secret-for-development-only"
3
+ JWT_SECRET="example-jwt-secret-for-development-only"
4
+ OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces"
5
+ OTEL_SERVICE_NAME="golf-mcp"
@@ -3,5 +3,7 @@
3
3
  "description": "A GolfMCP project",
4
4
  "host": "127.0.0.1",
5
5
  "port": 3000,
6
- "transport": "sse"
6
+ "transport": "sse",
7
+ "opentelemetry_enabled": true,
8
+ "opentelemetry_default_exporter": "otlp_http"
7
9
  }
@@ -0,0 +1,19 @@
1
+ """Golf telemetry module for OpenTelemetry instrumentation."""
2
+
3
+ from golf.telemetry.instrumentation import (
4
+ instrument_tool,
5
+ instrument_resource,
6
+ instrument_prompt,
7
+ telemetry_lifespan,
8
+ init_telemetry,
9
+ get_tracer,
10
+ )
11
+
12
+ __all__ = [
13
+ "instrument_tool",
14
+ "instrument_resource",
15
+ "instrument_prompt",
16
+ "telemetry_lifespan",
17
+ "init_telemetry",
18
+ "get_tracer",
19
+ ]
@@ -0,0 +1,540 @@
1
+ """Component-level OpenTelemetry instrumentation for Golf-built servers."""
2
+
3
+ import os
4
+ import sys
5
+ import functools
6
+ from typing import Callable, Optional, TypeVar
7
+ from contextlib import asynccontextmanager
8
+ import asyncio
9
+
10
+ from opentelemetry import trace
11
+ from opentelemetry.sdk.trace import TracerProvider
12
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
13
+ from opentelemetry.sdk.resources import Resource
14
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
15
+ from opentelemetry.trace import Status, StatusCode, Span
16
+ from opentelemetry import baggage
17
+
18
+ T = TypeVar('T')
19
+
20
+ # Global tracer instance
21
+ _tracer: Optional[trace.Tracer] = None
22
+ _provider: Optional[TracerProvider] = None
23
+ _instrumented_tools = []
24
+
25
+ def init_telemetry(service_name: str = "golf-mcp-server") -> Optional[TracerProvider]:
26
+ """Initialize OpenTelemetry with environment-based configuration.
27
+
28
+ Returns None if required environment variables are not set.
29
+ """
30
+ global _provider
31
+
32
+ # Check for required environment variables based on exporter type
33
+ exporter_type = os.environ.get("OTEL_TRACES_EXPORTER", "console").lower()
34
+
35
+ # For OTLP HTTP exporter, check if endpoint is configured
36
+ if exporter_type == "otlp_http":
37
+ endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
38
+ if not endpoint:
39
+ print(f"[WARNING] OpenTelemetry tracing is disabled: OTEL_EXPORTER_OTLP_ENDPOINT is not set for OTLP HTTP exporter")
40
+ return None
41
+
42
+ # Create resource with service information
43
+ resource_attributes = {
44
+ "service.name": os.environ.get("OTEL_SERVICE_NAME", service_name),
45
+ "service.version": os.environ.get("SERVICE_VERSION", "1.0.0"),
46
+ "service.instance.id": os.environ.get("SERVICE_INSTANCE_ID", "default"),
47
+ }
48
+ resource = Resource.create(resource_attributes)
49
+
50
+ # Create provider
51
+ provider = TracerProvider(resource=resource)
52
+
53
+ # Configure exporter based on type
54
+ try:
55
+ if exporter_type == "otlp_http":
56
+ endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces")
57
+ headers = os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "")
58
+
59
+ # Parse headers if provided
60
+ header_dict = {}
61
+ if headers:
62
+ for header in headers.split(","):
63
+ if "=" in header:
64
+ key, value = header.split("=", 1)
65
+ header_dict[key.strip()] = value.strip()
66
+
67
+ exporter = OTLPSpanExporter(
68
+ endpoint=endpoint,
69
+ headers=header_dict if header_dict else None
70
+ )
71
+ else:
72
+ # Default to console exporter
73
+ exporter = ConsoleSpanExporter(out=sys.stderr)
74
+ except Exception as e:
75
+ import traceback
76
+ traceback.print_exc()
77
+ raise
78
+
79
+ # Add batch processor for better performance
80
+ try:
81
+ processor = BatchSpanProcessor(
82
+ exporter,
83
+ max_queue_size=2048,
84
+ schedule_delay_millis=1000, # Export every 1 second instead of default 5 seconds
85
+ max_export_batch_size=512,
86
+ export_timeout_millis=5000
87
+ )
88
+ provider.add_span_processor(processor)
89
+ except Exception as e:
90
+ import traceback
91
+ traceback.print_exc()
92
+ raise
93
+
94
+ # Set as global provider
95
+ try:
96
+ # Check if a provider is already set to avoid the warning
97
+ existing_provider = trace.get_tracer_provider()
98
+ if existing_provider is None or str(type(existing_provider).__name__) == 'ProxyTracerProvider':
99
+ # Only set if no provider exists or it's the default proxy provider
100
+ trace.set_tracer_provider(provider)
101
+ _provider = provider
102
+ except Exception as e:
103
+ import traceback
104
+ traceback.print_exc()
105
+ raise
106
+
107
+ # Create a test span to verify everything is working
108
+ try:
109
+ test_tracer = provider.get_tracer("golf.telemetry.test", "1.0.0")
110
+ with test_tracer.start_as_current_span("startup.test") as span:
111
+ span.set_attribute("test", True)
112
+ span.set_attribute("service.name", service_name)
113
+ span.set_attribute("exporter.type", exporter_type)
114
+ span.set_attribute("endpoint", os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "not set"))
115
+ except Exception as e:
116
+ import traceback
117
+ traceback.print_exc()
118
+
119
+ return provider
120
+
121
+ def get_tracer() -> trace.Tracer:
122
+ """Get or create the global tracer instance."""
123
+ global _tracer, _provider
124
+
125
+ # If no provider is set, telemetry is disabled - return no-op tracer
126
+ if _provider is None:
127
+ return trace.get_tracer("golf.mcp.components.noop", "1.0.0")
128
+
129
+ if _tracer is None:
130
+ _tracer = trace.get_tracer("golf.mcp.components", "1.0.0")
131
+ return _tracer
132
+
133
+ def _add_component_attributes(span: Span, component_type: str, component_name: str, **kwargs):
134
+ """Add standard component attributes to a span."""
135
+ span.set_attribute("mcp.component.type", component_type)
136
+ span.set_attribute("mcp.component.name", component_name)
137
+
138
+ # Add any additional attributes
139
+ for key, value in kwargs.items():
140
+ if value is not None:
141
+ span.set_attribute(f"mcp.component.{key}", str(value))
142
+
143
+ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
144
+ """Instrument a tool function with OpenTelemetry tracing."""
145
+ global _provider
146
+
147
+ # If telemetry is disabled, return the original function
148
+ if _provider is None:
149
+ return func
150
+
151
+ tracer = get_tracer()
152
+
153
+ @functools.wraps(func)
154
+ async def async_wrapper(*args, **kwargs):
155
+ span = tracer.start_span(f"tool.{tool_name}")
156
+
157
+ # Activate the span in the current context
158
+ from opentelemetry import context
159
+ token = context.attach(trace.set_span_in_context(span))
160
+
161
+ try:
162
+ _add_component_attributes(span, "tool", tool_name,
163
+ args_count=len(args),
164
+ kwargs_count=len(kwargs))
165
+
166
+ # Extract Context parameter if present - this should have MCP session info
167
+ ctx = kwargs.get('ctx')
168
+ if ctx:
169
+ if hasattr(ctx, 'request_id'):
170
+ span.set_attribute("mcp.request.id", ctx.request_id)
171
+ if hasattr(ctx, 'session_id'):
172
+ span.set_attribute("mcp.session.id", ctx.session_id)
173
+ # Try to find any session-related attributes
174
+ for attr in dir(ctx):
175
+ if 'session' in attr.lower() and not attr.startswith('_'):
176
+ value = getattr(ctx, attr, None)
177
+ if value:
178
+ span.set_attribute(f"mcp.context.{attr}", str(value))
179
+
180
+ # Also check baggage for session ID
181
+ session_id_from_baggage = baggage.get_baggage("mcp.session.id")
182
+ if session_id_from_baggage:
183
+ span.set_attribute("mcp.session.id", session_id_from_baggage)
184
+
185
+ # Add tool arguments as span attributes (be careful with sensitive data)
186
+ for i, arg in enumerate(args):
187
+ if isinstance(arg, (str, int, float, bool)) or arg is None:
188
+ span.set_attribute(f"tool.arg.{i}", str(arg))
189
+ elif hasattr(arg, '__dict__'):
190
+ # For objects, just record the type
191
+ span.set_attribute(f"tool.arg.{i}.type", type(arg).__name__)
192
+
193
+ # Add named arguments
194
+ for key, value in kwargs.items():
195
+ if key != 'ctx':
196
+ if value is None:
197
+ span.set_attribute(f"tool.kwarg.{key}", "null")
198
+ elif isinstance(value, (str, int, float, bool)):
199
+ span.set_attribute(f"tool.kwarg.{key}", str(value))
200
+ elif isinstance(value, (list, tuple)):
201
+ span.set_attribute(f"tool.kwarg.{key}", f"[{len(value)} items]")
202
+ elif isinstance(value, dict):
203
+ span.set_attribute(f"tool.kwarg.{key}", f"{{dict with {len(value)} keys}}")
204
+ else:
205
+ # For other types, at least record the type
206
+ span.set_attribute(f"tool.kwarg.{key}.type", type(value).__name__)
207
+
208
+ try:
209
+ result = await func(*args, **kwargs)
210
+ span.set_status(Status(StatusCode.OK))
211
+
212
+ # Capture result metadata
213
+ if result is not None:
214
+ if isinstance(result, (str, int, float, bool)):
215
+ span.set_attribute("tool.result", str(result))
216
+ elif isinstance(result, list):
217
+ span.set_attribute("tool.result.count", len(result))
218
+ span.set_attribute("tool.result.type", "list")
219
+ elif isinstance(result, dict):
220
+ span.set_attribute("tool.result.keys", ",".join(result.keys()) if len(result) < 10 else f"{len(result)} keys")
221
+ span.set_attribute("tool.result.type", "dict")
222
+ elif hasattr(result, '__len__'):
223
+ span.set_attribute("tool.result.length", len(result))
224
+
225
+ # For any result, record its type
226
+ span.set_attribute("tool.result.class", type(result).__name__)
227
+
228
+ return result
229
+ except Exception as e:
230
+ span.record_exception(e)
231
+ span.set_status(Status(StatusCode.ERROR, str(e)))
232
+ raise
233
+ finally:
234
+ # End the span and detach context
235
+ span.end()
236
+ context.detach(token)
237
+
238
+ # Force flush the provider to ensure spans are exported
239
+ global _provider
240
+ if _provider:
241
+ try:
242
+ _provider.force_flush(timeout_millis=1000)
243
+ except Exception as e:
244
+ pass
245
+
246
+ @functools.wraps(func)
247
+ def sync_wrapper(*args, **kwargs):
248
+ span = tracer.start_span(f"tool.{tool_name}")
249
+
250
+ # Activate the span in the current context
251
+ from opentelemetry import context
252
+ token = context.attach(trace.set_span_in_context(span))
253
+
254
+ try:
255
+ _add_component_attributes(span, "tool", tool_name,
256
+ args_count=len(args),
257
+ kwargs_count=len(kwargs))
258
+
259
+ # Extract Context parameter if present - this should have MCP session info
260
+ ctx = kwargs.get('ctx')
261
+ if ctx:
262
+ if hasattr(ctx, 'request_id'):
263
+ span.set_attribute("mcp.request.id", ctx.request_id)
264
+ if hasattr(ctx, 'session_id'):
265
+ span.set_attribute("mcp.session.id", ctx.session_id)
266
+ # Try to find any session-related attributes
267
+ for attr in dir(ctx):
268
+ if 'session' in attr.lower() and not attr.startswith('_'):
269
+ value = getattr(ctx, attr, None)
270
+ if value:
271
+ span.set_attribute(f"mcp.context.{attr}", str(value))
272
+
273
+ # Also check baggage for session ID
274
+ session_id_from_baggage = baggage.get_baggage("mcp.session.id")
275
+ if session_id_from_baggage:
276
+ span.set_attribute("mcp.session.id", session_id_from_baggage)
277
+
278
+ # Add tool arguments as span attributes (be careful with sensitive data)
279
+ for i, arg in enumerate(args):
280
+ if isinstance(arg, (str, int, float, bool)) or arg is None:
281
+ span.set_attribute(f"tool.arg.{i}", str(arg))
282
+ elif hasattr(arg, '__dict__'):
283
+ # For objects, just record the type
284
+ span.set_attribute(f"tool.arg.{i}.type", type(arg).__name__)
285
+
286
+ # Add named arguments
287
+ for key, value in kwargs.items():
288
+ if key != 'ctx':
289
+ if value is None:
290
+ span.set_attribute(f"tool.kwarg.{key}", "null")
291
+ elif isinstance(value, (str, int, float, bool)):
292
+ span.set_attribute(f"tool.kwarg.{key}", str(value))
293
+ elif isinstance(value, (list, tuple)):
294
+ span.set_attribute(f"tool.kwarg.{key}", f"[{len(value)} items]")
295
+ elif isinstance(value, dict):
296
+ span.set_attribute(f"tool.kwarg.{key}", f"{{dict with {len(value)} keys}}")
297
+ else:
298
+ # For other types, at least record the type
299
+ span.set_attribute(f"tool.kwarg.{key}.type", type(value).__name__)
300
+
301
+ try:
302
+ result = func(*args, **kwargs)
303
+ span.set_status(Status(StatusCode.OK))
304
+
305
+ # Capture result metadata
306
+ if result is not None:
307
+ if isinstance(result, (str, int, float, bool)):
308
+ span.set_attribute("tool.result", str(result))
309
+ elif isinstance(result, list):
310
+ span.set_attribute("tool.result.count", len(result))
311
+ span.set_attribute("tool.result.type", "list")
312
+ elif isinstance(result, dict):
313
+ span.set_attribute("tool.result.keys", ",".join(result.keys()) if len(result) < 10 else f"{len(result)} keys")
314
+ span.set_attribute("tool.result.type", "dict")
315
+ elif hasattr(result, '__len__'):
316
+ span.set_attribute("tool.result.length", len(result))
317
+
318
+ # For any result, record its type
319
+ span.set_attribute("tool.result.class", type(result).__name__)
320
+
321
+ return result
322
+ except Exception as e:
323
+ span.record_exception(e)
324
+ span.set_status(Status(StatusCode.ERROR, str(e)))
325
+ raise
326
+ finally:
327
+ # End the span and detach context
328
+ span.end()
329
+ context.detach(token)
330
+
331
+ # Force flush the provider to ensure spans are exported
332
+ global _provider
333
+ if _provider:
334
+ try:
335
+ _provider.force_flush(timeout_millis=1000)
336
+ except Exception as e:
337
+ pass
338
+
339
+ # Return appropriate wrapper based on function type
340
+ if asyncio.iscoroutinefunction(func):
341
+ return async_wrapper
342
+ else:
343
+ return sync_wrapper
344
+
345
+ def instrument_resource(func: Callable[..., T], resource_uri: str) -> Callable[..., T]:
346
+ """Instrument a resource function with OpenTelemetry tracing."""
347
+ global _provider
348
+
349
+ # If telemetry is disabled, return the original function
350
+ if _provider is None:
351
+ return func
352
+
353
+ tracer = get_tracer()
354
+
355
+ # Determine if this is a template based on URI pattern
356
+ is_template = '{' in resource_uri
357
+
358
+ @functools.wraps(func)
359
+ async def async_wrapper(*args, **kwargs):
360
+ span_name = "resource.template.read" if is_template else "resource.read"
361
+ with tracer.start_as_current_span(span_name) as span:
362
+ _add_component_attributes(span, "resource", resource_uri,
363
+ is_template=is_template)
364
+
365
+ # Extract Context parameter if present
366
+ ctx = kwargs.get('ctx')
367
+ if ctx and hasattr(ctx, 'request_id'):
368
+ span.set_attribute("mcp.request.id", ctx.request_id)
369
+
370
+ try:
371
+ result = await func(*args, **kwargs)
372
+ span.set_status(Status(StatusCode.OK))
373
+
374
+ # Add result size if applicable
375
+ if hasattr(result, '__len__'):
376
+ span.set_attribute("mcp.resource.size", len(result))
377
+
378
+ return result
379
+ except Exception as e:
380
+ span.record_exception(e)
381
+ span.set_status(Status(StatusCode.ERROR, str(e)))
382
+ raise
383
+
384
+ @functools.wraps(func)
385
+ def sync_wrapper(*args, **kwargs):
386
+ span_name = "resource.template.read" if is_template else "resource.read"
387
+ with tracer.start_as_current_span(span_name) as span:
388
+ _add_component_attributes(span, "resource", resource_uri,
389
+ is_template=is_template)
390
+
391
+ # Extract Context parameter if present
392
+ ctx = kwargs.get('ctx')
393
+ if ctx and hasattr(ctx, 'request_id'):
394
+ span.set_attribute("mcp.request.id", ctx.request_id)
395
+
396
+ try:
397
+ result = func(*args, **kwargs)
398
+ span.set_status(Status(StatusCode.OK))
399
+
400
+ # Add result size if applicable
401
+ if hasattr(result, '__len__'):
402
+ span.set_attribute("mcp.resource.size", len(result))
403
+
404
+ return result
405
+ except Exception as e:
406
+ span.record_exception(e)
407
+ span.set_status(Status(StatusCode.ERROR, str(e)))
408
+ raise
409
+
410
+ if asyncio.iscoroutinefunction(func):
411
+ return async_wrapper
412
+ else:
413
+ return sync_wrapper
414
+
415
+ def instrument_prompt(func: Callable[..., T], prompt_name: str) -> Callable[..., T]:
416
+ """Instrument a prompt function with OpenTelemetry tracing."""
417
+ global _provider
418
+
419
+ # If telemetry is disabled, return the original function
420
+ if _provider is None:
421
+ return func
422
+
423
+ tracer = get_tracer()
424
+
425
+ @functools.wraps(func)
426
+ async def async_wrapper(*args, **kwargs):
427
+ with tracer.start_as_current_span(f"prompt.{prompt_name}") as span:
428
+ _add_component_attributes(span, "prompt", prompt_name)
429
+
430
+ # Extract Context parameter if present
431
+ ctx = kwargs.get('ctx')
432
+ if ctx and hasattr(ctx, 'request_id'):
433
+ span.set_attribute("mcp.request.id", ctx.request_id)
434
+
435
+ try:
436
+ result = await func(*args, **kwargs)
437
+ span.set_status(Status(StatusCode.OK))
438
+
439
+ # Add message count if result is a list
440
+ if isinstance(result, list):
441
+ span.set_attribute("mcp.prompt.message_count", len(result))
442
+
443
+ return result
444
+ except Exception as e:
445
+ span.record_exception(e)
446
+ span.set_status(Status(StatusCode.ERROR, str(e)))
447
+ raise
448
+
449
+ @functools.wraps(func)
450
+ def sync_wrapper(*args, **kwargs):
451
+ with tracer.start_as_current_span(f"prompt.{prompt_name}") as span:
452
+ _add_component_attributes(span, "prompt", prompt_name)
453
+
454
+ # Extract Context parameter if present
455
+ ctx = kwargs.get('ctx')
456
+ if ctx and hasattr(ctx, 'request_id'):
457
+ span.set_attribute("mcp.request.id", ctx.request_id)
458
+
459
+ try:
460
+ result = func(*args, **kwargs)
461
+ span.set_status(Status(StatusCode.OK))
462
+
463
+ # Add message count if result is a list
464
+ if isinstance(result, list):
465
+ span.set_attribute("mcp.prompt.message_count", len(result))
466
+
467
+ return result
468
+ except Exception as e:
469
+ span.record_exception(e)
470
+ span.set_status(Status(StatusCode.ERROR, str(e)))
471
+ raise
472
+
473
+ if asyncio.iscoroutinefunction(func):
474
+ return async_wrapper
475
+ else:
476
+ return sync_wrapper
477
+
478
+ @asynccontextmanager
479
+ async def telemetry_lifespan(mcp_instance):
480
+ """Simplified lifespan for telemetry initialization and cleanup."""
481
+ global _provider, _instrumented_tools
482
+
483
+ # Initialize telemetry with the server name
484
+ provider = init_telemetry(service_name=mcp_instance.name)
485
+
486
+ # If provider is None, telemetry is disabled
487
+ if provider is None:
488
+ # Just yield without any telemetry setup
489
+ yield
490
+ return
491
+
492
+ # Try to add session tracking middleware if possible
493
+ try:
494
+ from starlette.middleware.base import BaseHTTPMiddleware
495
+ from starlette.requests import Request
496
+
497
+ class SessionTracingMiddleware(BaseHTTPMiddleware):
498
+ async def dispatch(self, request: Request, call_next):
499
+ # Extract session ID from query params
500
+ session_id = request.query_params.get('session_id')
501
+ if session_id:
502
+ # Add to baggage for propagation
503
+ ctx = baggage.set_baggage("mcp.session.id", session_id)
504
+ from opentelemetry import context
505
+ token = context.attach(ctx)
506
+
507
+ # Also create a span for the HTTP request
508
+ tracer = get_tracer()
509
+ with tracer.start_as_current_span(f"http.{request.method} {request.url.path}") as span:
510
+ span.set_attribute("http.method", request.method)
511
+ span.set_attribute("http.url", str(request.url))
512
+ span.set_attribute("http.session_id", session_id)
513
+ span.set_attribute("mcp.session.id", session_id)
514
+
515
+ try:
516
+ response = await call_next(request)
517
+ span.set_attribute("http.status_code", response.status_code)
518
+ return response
519
+ finally:
520
+ context.detach(token)
521
+ else:
522
+ return await call_next(request)
523
+
524
+ # Try to add middleware to FastMCP app if it has Starlette app
525
+ if hasattr(mcp_instance, 'app') or hasattr(mcp_instance, '_app'):
526
+ app = getattr(mcp_instance, 'app', getattr(mcp_instance, '_app', None))
527
+ if app and hasattr(app, 'add_middleware'):
528
+ app.add_middleware(SessionTracingMiddleware)
529
+ except Exception:
530
+ pass
531
+
532
+ try:
533
+ # Yield control back to FastMCP
534
+ yield
535
+ finally:
536
+ # Cleanup - shutdown the provider
537
+ if _provider and hasattr(_provider, 'shutdown'):
538
+ _provider.force_flush()
539
+ _provider.shutdown()
540
+ _provider = None