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.
- golf/__init__.py +1 -1
- golf/auth/api_key.py +19 -4
- golf/core/builder.py +233 -278
- golf/core/builder_auth.py +153 -37
- golf/core/builder_telemetry.py +44 -179
- golf/core/telemetry.py +50 -8
- golf/examples/api_key/.env +2 -0
- golf/examples/api_key/.env.example +5 -0
- golf/examples/api_key/README.md +21 -7
- golf/examples/api_key/golf.json +3 -1
- golf/examples/api_key/pre_build.py +2 -1
- golf/examples/basic/.env +3 -1
- golf/examples/basic/.env.example +3 -1
- golf/examples/basic/golf.json +3 -1
- golf/telemetry/__init__.py +19 -0
- golf/telemetry/instrumentation.py +540 -0
- {golf_mcp-0.1.6.dist-info → golf_mcp-0.1.8.dist-info}/METADATA +41 -2
- {golf_mcp-0.1.6.dist-info → golf_mcp-0.1.8.dist-info}/RECORD +22 -18
- {golf_mcp-0.1.6.dist-info → golf_mcp-0.1.8.dist-info}/WHEEL +0 -0
- {golf_mcp-0.1.6.dist-info → golf_mcp-0.1.8.dist-info}/entry_points.txt +0 -0
- {golf_mcp-0.1.6.dist-info → golf_mcp-0.1.8.dist-info}/licenses/LICENSE +0 -0
- {golf_mcp-0.1.6.dist-info → golf_mcp-0.1.8.dist-info}/top_level.txt +0 -0
golf/examples/api_key/README.md
CHANGED
|
@@ -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
|
|
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**
|
|
48
|
-
3. **
|
|
49
|
-
4. **
|
|
50
|
-
5. **
|
|
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
|
|
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
|
|
golf/examples/api_key/golf.json
CHANGED
|
@@ -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"
|
golf/examples/basic/.env.example
CHANGED
|
@@ -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"
|
golf/examples/basic/golf.json
CHANGED
|
@@ -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
|