golf-mcp 0.1.17__py3-none-any.whl → 0.1.19__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/core/builder.py +83 -1
- golf/core/builder_auth.py +5 -0
- golf/core/builder_metrics.py +232 -0
- golf/core/config.py +6 -0
- golf/metrics/__init__.py +10 -0
- golf/metrics/collector.py +239 -0
- golf/metrics/registry.py +12 -0
- golf/telemetry/instrumentation.py +297 -204
- {golf_mcp-0.1.17.dist-info → golf_mcp-0.1.19.dist-info}/METADATA +3 -1
- {golf_mcp-0.1.17.dist-info → golf_mcp-0.1.19.dist-info}/RECORD +15 -11
- {golf_mcp-0.1.17.dist-info → golf_mcp-0.1.19.dist-info}/WHEEL +0 -0
- {golf_mcp-0.1.17.dist-info → golf_mcp-0.1.19.dist-info}/entry_points.txt +0 -0
- {golf_mcp-0.1.17.dist-info → golf_mcp-0.1.19.dist-info}/licenses/LICENSE +0 -0
- {golf_mcp-0.1.17.dist-info → golf_mcp-0.1.19.dist-info}/top_level.txt +0 -0
golf/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.19"
|
golf/core/builder.py
CHANGED
|
@@ -562,6 +562,18 @@ class CodeGenerator:
|
|
|
562
562
|
if self.settings.opentelemetry_enabled:
|
|
563
563
|
imports.extend(generate_telemetry_imports())
|
|
564
564
|
|
|
565
|
+
# Add metrics imports if enabled
|
|
566
|
+
if self.settings.metrics_enabled:
|
|
567
|
+
from golf.core.builder_metrics import (
|
|
568
|
+
generate_metrics_imports,
|
|
569
|
+
generate_metrics_instrumentation,
|
|
570
|
+
generate_session_tracking,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
imports.extend(generate_metrics_imports())
|
|
574
|
+
imports.extend(generate_metrics_instrumentation())
|
|
575
|
+
imports.extend(generate_session_tracking())
|
|
576
|
+
|
|
565
577
|
# Add health check imports if enabled
|
|
566
578
|
if self.settings.health_check_enabled:
|
|
567
579
|
imports.extend(
|
|
@@ -672,6 +684,44 @@ class CodeGenerator:
|
|
|
672
684
|
f"{full_module_path}.{entry_func}, '{component.name}')"
|
|
673
685
|
)
|
|
674
686
|
|
|
687
|
+
if component_type == ComponentType.TOOL:
|
|
688
|
+
registration += (
|
|
689
|
+
f'\nmcp.add_tool(_wrapped_func, name="{component.name}", '
|
|
690
|
+
f'description="{component.docstring or ""}"'
|
|
691
|
+
)
|
|
692
|
+
# Add annotations if present
|
|
693
|
+
if hasattr(component, "annotations") and component.annotations:
|
|
694
|
+
registration += f", annotations={component.annotations}"
|
|
695
|
+
registration += ")"
|
|
696
|
+
elif component_type == ComponentType.RESOURCE:
|
|
697
|
+
registration += (
|
|
698
|
+
f"\nmcp.add_resource_fn(_wrapped_func, "
|
|
699
|
+
f'uri="{component.uri_template}", name="{component.name}", '
|
|
700
|
+
f'description="{component.docstring or ""}")'
|
|
701
|
+
)
|
|
702
|
+
else: # PROMPT
|
|
703
|
+
registration += (
|
|
704
|
+
f'\nmcp.add_prompt(_wrapped_func, name="{component.name}", '
|
|
705
|
+
f'description="{component.docstring or ""}")'
|
|
706
|
+
)
|
|
707
|
+
elif self.settings.metrics_enabled:
|
|
708
|
+
# Use metrics instrumentation
|
|
709
|
+
registration = (
|
|
710
|
+
f"# Register the {component_type.value} "
|
|
711
|
+
f"'{component.name}' with metrics"
|
|
712
|
+
)
|
|
713
|
+
entry_func = (
|
|
714
|
+
component.entry_function
|
|
715
|
+
if hasattr(component, "entry_function")
|
|
716
|
+
and component.entry_function
|
|
717
|
+
else "export"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
registration += (
|
|
721
|
+
f"\n_wrapped_func = instrument_{component_type.value}("
|
|
722
|
+
f"{full_module_path}.{entry_func}, '{component.name}')"
|
|
723
|
+
)
|
|
724
|
+
|
|
675
725
|
if component_type == ComponentType.TOOL:
|
|
676
726
|
registration += (
|
|
677
727
|
f'\nmcp.add_tool(_wrapped_func, name="{component.name}", '
|
|
@@ -826,6 +876,15 @@ class CodeGenerator:
|
|
|
826
876
|
]
|
|
827
877
|
)
|
|
828
878
|
|
|
879
|
+
# Add metrics initialization if enabled
|
|
880
|
+
early_metrics_init = []
|
|
881
|
+
if self.settings.metrics_enabled:
|
|
882
|
+
from golf.core.builder_metrics import generate_metrics_initialization
|
|
883
|
+
|
|
884
|
+
early_metrics_init.extend(
|
|
885
|
+
generate_metrics_initialization(self.settings.name)
|
|
886
|
+
)
|
|
887
|
+
|
|
829
888
|
# Main entry point with transport-specific app initialization
|
|
830
889
|
main_code = [
|
|
831
890
|
'if __name__ == "__main__":',
|
|
@@ -864,6 +923,13 @@ class CodeGenerator:
|
|
|
864
923
|
)
|
|
865
924
|
middleware_list.append("Middleware(ApiKeyMiddleware)")
|
|
866
925
|
|
|
926
|
+
# Add metrics middleware if enabled
|
|
927
|
+
if self.settings.metrics_enabled:
|
|
928
|
+
middleware_setup.append(
|
|
929
|
+
" from starlette.middleware import Middleware"
|
|
930
|
+
)
|
|
931
|
+
middleware_list.append("Middleware(MetricsMiddleware)")
|
|
932
|
+
|
|
867
933
|
# Add OpenTelemetry middleware if enabled
|
|
868
934
|
if self.settings.opentelemetry_enabled:
|
|
869
935
|
middleware_setup.append(
|
|
@@ -904,6 +970,13 @@ class CodeGenerator:
|
|
|
904
970
|
)
|
|
905
971
|
middleware_list.append("Middleware(ApiKeyMiddleware)")
|
|
906
972
|
|
|
973
|
+
# Add metrics middleware if enabled
|
|
974
|
+
if self.settings.metrics_enabled:
|
|
975
|
+
middleware_setup.append(
|
|
976
|
+
" from starlette.middleware import Middleware"
|
|
977
|
+
)
|
|
978
|
+
middleware_list.append("Middleware(MetricsMiddleware)")
|
|
979
|
+
|
|
907
980
|
# Add OpenTelemetry middleware if enabled
|
|
908
981
|
if self.settings.opentelemetry_enabled:
|
|
909
982
|
middleware_setup.append(
|
|
@@ -937,6 +1010,13 @@ class CodeGenerator:
|
|
|
937
1010
|
[" # Run with stdio transport", ' mcp.run(transport="stdio")']
|
|
938
1011
|
)
|
|
939
1012
|
|
|
1013
|
+
# Add metrics route if enabled
|
|
1014
|
+
metrics_route_code = []
|
|
1015
|
+
if self.settings.metrics_enabled:
|
|
1016
|
+
from golf.core.builder_metrics import generate_metrics_route
|
|
1017
|
+
|
|
1018
|
+
metrics_route_code = generate_metrics_route(self.settings.metrics_path)
|
|
1019
|
+
|
|
940
1020
|
# Add health check route if enabled
|
|
941
1021
|
health_check_code = []
|
|
942
1022
|
if self.settings.health_check_enabled:
|
|
@@ -953,14 +1033,16 @@ class CodeGenerator:
|
|
|
953
1033
|
|
|
954
1034
|
# Combine all sections
|
|
955
1035
|
# Order: imports, env_section, auth_setup, server_code (mcp init),
|
|
956
|
-
# early_telemetry_init, component_registrations, health_check_code, main_code (run block)
|
|
1036
|
+
# early_telemetry_init, early_metrics_init, component_registrations, metrics_route_code, health_check_code, main_code (run block)
|
|
957
1037
|
code = "\n".join(
|
|
958
1038
|
imports
|
|
959
1039
|
+ env_section
|
|
960
1040
|
+ auth_setup_code
|
|
961
1041
|
+ server_code_lines
|
|
962
1042
|
+ early_telemetry_init
|
|
1043
|
+
+ early_metrics_init
|
|
963
1044
|
+ component_registrations
|
|
1045
|
+
+ metrics_route_code
|
|
964
1046
|
+ health_check_code
|
|
965
1047
|
+ main_code
|
|
966
1048
|
)
|
golf/core/builder_auth.py
CHANGED
|
@@ -187,6 +187,11 @@ def generate_api_key_auth_components(
|
|
|
187
187
|
" # Debug mode from environment",
|
|
188
188
|
" debug = os.environ.get('GOLF_API_KEY_DEBUG', '').lower() == 'true'",
|
|
189
189
|
" ",
|
|
190
|
+
" # Skip auth for monitoring endpoints",
|
|
191
|
+
" path = request.url.path",
|
|
192
|
+
" if path in ['/metrics', '/health']:",
|
|
193
|
+
" return await call_next(request)",
|
|
194
|
+
" ",
|
|
190
195
|
" api_key_config = get_api_key_config()",
|
|
191
196
|
" ",
|
|
192
197
|
" if api_key_config:",
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Metrics integration for the GolfMCP build process.
|
|
2
|
+
|
|
3
|
+
This module provides functions for generating Prometheus metrics initialization
|
|
4
|
+
and collection code for FastMCP servers built with GolfMCP.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_metrics_imports() -> list[str]:
|
|
9
|
+
"""Generate import statements for metrics collection.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
List of import statements for metrics
|
|
13
|
+
"""
|
|
14
|
+
return [
|
|
15
|
+
"# Prometheus metrics imports",
|
|
16
|
+
"from golf.metrics import init_metrics, get_metrics_collector",
|
|
17
|
+
"from prometheus_client import generate_latest, CONTENT_TYPE_LATEST",
|
|
18
|
+
"from starlette.responses import Response",
|
|
19
|
+
"from starlette.middleware.base import BaseHTTPMiddleware",
|
|
20
|
+
"from starlette.requests import Request",
|
|
21
|
+
"import time",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate_metrics_initialization(server_name: str) -> list[str]:
|
|
26
|
+
"""Generate metrics initialization code.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
server_name: Name of the MCP server
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
List of code lines for metrics initialization
|
|
33
|
+
"""
|
|
34
|
+
return [
|
|
35
|
+
"# Initialize metrics collection",
|
|
36
|
+
"init_metrics(enabled=True)",
|
|
37
|
+
"",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_metrics_route(metrics_path: str) -> list[str]:
|
|
42
|
+
"""Generate the metrics endpoint route code.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
metrics_path: Path for the metrics endpoint (e.g., "/metrics")
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of code lines for the metrics route
|
|
49
|
+
"""
|
|
50
|
+
return [
|
|
51
|
+
"# Add metrics endpoint",
|
|
52
|
+
f'@mcp.custom_route("{metrics_path}", methods=["GET"])',
|
|
53
|
+
"async def metrics_endpoint(request):",
|
|
54
|
+
' """Prometheus metrics endpoint for monitoring."""',
|
|
55
|
+
" # Update uptime before returning metrics",
|
|
56
|
+
" update_uptime()",
|
|
57
|
+
" return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)",
|
|
58
|
+
"",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_metrics_dependencies() -> list[str]:
|
|
63
|
+
"""Get list of metrics dependencies to add to pyproject.toml.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of package requirements strings
|
|
67
|
+
"""
|
|
68
|
+
return [
|
|
69
|
+
"prometheus-client>=0.19.0",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generate_metrics_instrumentation() -> list[str]:
|
|
74
|
+
"""Generate metrics instrumentation wrapper functions.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of code lines for metrics instrumentation
|
|
78
|
+
"""
|
|
79
|
+
return [
|
|
80
|
+
"# Metrics instrumentation wrapper functions",
|
|
81
|
+
"import time",
|
|
82
|
+
"import functools",
|
|
83
|
+
"from typing import Any, Callable",
|
|
84
|
+
"",
|
|
85
|
+
"def instrument_tool(func: Callable, tool_name: str) -> Callable:",
|
|
86
|
+
' """Wrap a tool function with metrics collection."""',
|
|
87
|
+
" @functools.wraps(func)",
|
|
88
|
+
" async def wrapper(*args, **kwargs) -> Any:",
|
|
89
|
+
" collector = get_metrics_collector()",
|
|
90
|
+
" start_time = time.time()",
|
|
91
|
+
" status = 'success'",
|
|
92
|
+
" try:",
|
|
93
|
+
" result = await func(*args, **kwargs)",
|
|
94
|
+
" return result",
|
|
95
|
+
" except Exception as e:",
|
|
96
|
+
" status = 'error'",
|
|
97
|
+
" collector.increment_error('tool', type(e).__name__)",
|
|
98
|
+
" raise",
|
|
99
|
+
" finally:",
|
|
100
|
+
" duration = time.time() - start_time",
|
|
101
|
+
" collector.increment_tool_execution(tool_name, status)",
|
|
102
|
+
" collector.record_tool_duration(tool_name, duration)",
|
|
103
|
+
" return wrapper",
|
|
104
|
+
"",
|
|
105
|
+
"def instrument_resource(func: Callable, resource_name: str) -> Callable:",
|
|
106
|
+
' """Wrap a resource function with metrics collection."""',
|
|
107
|
+
" @functools.wraps(func)",
|
|
108
|
+
" async def wrapper(*args, **kwargs) -> Any:",
|
|
109
|
+
" collector = get_metrics_collector()",
|
|
110
|
+
" try:",
|
|
111
|
+
" result = await func(*args, **kwargs)",
|
|
112
|
+
" # Extract URI from args if available for resource_reads metric",
|
|
113
|
+
" if args and len(args) > 0:",
|
|
114
|
+
" uri = str(args[0]) if args[0] else resource_name",
|
|
115
|
+
" else:",
|
|
116
|
+
" uri = resource_name",
|
|
117
|
+
" collector.increment_resource_read(uri)",
|
|
118
|
+
" return result",
|
|
119
|
+
" except Exception as e:",
|
|
120
|
+
" collector.increment_error('resource', type(e).__name__)",
|
|
121
|
+
" raise",
|
|
122
|
+
" return wrapper",
|
|
123
|
+
"",
|
|
124
|
+
"def instrument_prompt(func: Callable, prompt_name: str) -> Callable:",
|
|
125
|
+
' """Wrap a prompt function with metrics collection."""',
|
|
126
|
+
" @functools.wraps(func)",
|
|
127
|
+
" async def wrapper(*args, **kwargs) -> Any:",
|
|
128
|
+
" collector = get_metrics_collector()",
|
|
129
|
+
" try:",
|
|
130
|
+
" result = await func(*args, **kwargs)",
|
|
131
|
+
" collector.increment_prompt_generation(prompt_name)",
|
|
132
|
+
" return result",
|
|
133
|
+
" except Exception as e:",
|
|
134
|
+
" collector.increment_error('prompt', type(e).__name__)",
|
|
135
|
+
" raise",
|
|
136
|
+
" return wrapper",
|
|
137
|
+
"",
|
|
138
|
+
"# HTTP Request Metrics Middleware",
|
|
139
|
+
"class MetricsMiddleware(BaseHTTPMiddleware):",
|
|
140
|
+
' """Middleware to collect HTTP request metrics."""',
|
|
141
|
+
"",
|
|
142
|
+
" async def dispatch(self, request: Request, call_next):",
|
|
143
|
+
" collector = get_metrics_collector()",
|
|
144
|
+
" start_time = time.time()",
|
|
145
|
+
" ",
|
|
146
|
+
" # Extract path and method",
|
|
147
|
+
" method = request.method",
|
|
148
|
+
" path = request.url.path",
|
|
149
|
+
" ",
|
|
150
|
+
" try:",
|
|
151
|
+
" response = await call_next(request)",
|
|
152
|
+
" status_code = response.status_code",
|
|
153
|
+
" except Exception as e:",
|
|
154
|
+
" status_code = 500",
|
|
155
|
+
" collector.increment_error('http', type(e).__name__)",
|
|
156
|
+
" raise",
|
|
157
|
+
" finally:",
|
|
158
|
+
" duration = time.time() - start_time",
|
|
159
|
+
" collector.increment_http_request(method, status_code, path)",
|
|
160
|
+
" collector.record_http_duration(method, path, duration)",
|
|
161
|
+
" ",
|
|
162
|
+
" return response",
|
|
163
|
+
"",
|
|
164
|
+
"# Session tracking helpers",
|
|
165
|
+
"import atexit",
|
|
166
|
+
"from contextlib import asynccontextmanager",
|
|
167
|
+
"",
|
|
168
|
+
"# Global server start time for uptime tracking",
|
|
169
|
+
"_server_start_time = time.time()",
|
|
170
|
+
"",
|
|
171
|
+
"def track_session_start():",
|
|
172
|
+
' """Track when a new session starts."""',
|
|
173
|
+
" collector = get_metrics_collector()",
|
|
174
|
+
" collector.increment_session()",
|
|
175
|
+
"",
|
|
176
|
+
"def track_session_end(start_time: float):",
|
|
177
|
+
' """Track when a session ends."""',
|
|
178
|
+
" collector = get_metrics_collector()",
|
|
179
|
+
" duration = time.time() - start_time",
|
|
180
|
+
" collector.record_session_duration(duration)",
|
|
181
|
+
"",
|
|
182
|
+
"def update_uptime():",
|
|
183
|
+
' """Update the uptime metric."""',
|
|
184
|
+
" collector = get_metrics_collector()",
|
|
185
|
+
" uptime = time.time() - _server_start_time",
|
|
186
|
+
" collector.set_uptime(uptime)",
|
|
187
|
+
"",
|
|
188
|
+
"# Initialize uptime tracking",
|
|
189
|
+
"update_uptime()",
|
|
190
|
+
"",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def generate_session_tracking() -> list[str]:
|
|
195
|
+
"""Generate session tracking integration code.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
List of code lines for session tracking
|
|
199
|
+
"""
|
|
200
|
+
return [
|
|
201
|
+
"# Session tracking integration",
|
|
202
|
+
"import asyncio",
|
|
203
|
+
"from typing import Dict",
|
|
204
|
+
"",
|
|
205
|
+
"# Track active sessions",
|
|
206
|
+
"_active_sessions: Dict[str, float] = {}",
|
|
207
|
+
"",
|
|
208
|
+
"# Hook into FastMCP's session lifecycle if available",
|
|
209
|
+
"try:",
|
|
210
|
+
" from fastmcp.server import SessionManager",
|
|
211
|
+
" ",
|
|
212
|
+
" # Monkey patch session creation if possible",
|
|
213
|
+
" _original_create_session = getattr(mcp, '_create_session', None)",
|
|
214
|
+
" if _original_create_session:",
|
|
215
|
+
" async def _patched_create_session(*args, **kwargs):",
|
|
216
|
+
" session_id = str(id(args)) if args else 'unknown'",
|
|
217
|
+
" _active_sessions[session_id] = time.time()",
|
|
218
|
+
" track_session_start()",
|
|
219
|
+
" try:",
|
|
220
|
+
" return await _original_create_session(*args, **kwargs)",
|
|
221
|
+
" except Exception:",
|
|
222
|
+
" # If session creation fails, clean up",
|
|
223
|
+
" if session_id in _active_sessions:",
|
|
224
|
+
" del _active_sessions[session_id]",
|
|
225
|
+
" raise",
|
|
226
|
+
" ",
|
|
227
|
+
" mcp._create_session = _patched_create_session",
|
|
228
|
+
"except (ImportError, AttributeError):",
|
|
229
|
+
" # Fallback: track sessions via request patterns",
|
|
230
|
+
" pass",
|
|
231
|
+
"",
|
|
232
|
+
]
|
golf/core/config.py
CHANGED
|
@@ -115,6 +115,12 @@ class Settings(BaseSettings):
|
|
|
115
115
|
description="Make Streamable-HTTP transport stateless (new session per request)",
|
|
116
116
|
)
|
|
117
117
|
|
|
118
|
+
# Metrics configuration
|
|
119
|
+
metrics_enabled: bool = Field(
|
|
120
|
+
False, description="Enable Prometheus metrics endpoint"
|
|
121
|
+
)
|
|
122
|
+
metrics_path: str = Field("/metrics", description="Metrics endpoint path")
|
|
123
|
+
|
|
118
124
|
|
|
119
125
|
def find_config_path(start_path: Path | None = None) -> Path | None:
|
|
120
126
|
"""Find the golf config file by searching upwards from the given path.
|
golf/metrics/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Golf metrics module for Prometheus-compatible metrics collection."""
|
|
2
|
+
|
|
3
|
+
from golf.metrics.collector import MetricsCollector, get_metrics_collector
|
|
4
|
+
from golf.metrics.registry import init_metrics
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"MetricsCollector",
|
|
8
|
+
"get_metrics_collector",
|
|
9
|
+
"init_metrics",
|
|
10
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Metrics collector for Golf MCP servers."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
# Global metrics collector instance
|
|
6
|
+
_metrics_collector: Optional["MetricsCollector"] = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MetricsCollector:
|
|
10
|
+
"""Collects metrics for Golf MCP servers using Prometheus client."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, enabled: bool = False) -> None:
|
|
13
|
+
"""Initialize the metrics collector.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
enabled: Whether metrics collection is enabled
|
|
17
|
+
"""
|
|
18
|
+
self.enabled = enabled
|
|
19
|
+
self._metrics = {}
|
|
20
|
+
|
|
21
|
+
if self.enabled:
|
|
22
|
+
self._init_prometheus_metrics()
|
|
23
|
+
|
|
24
|
+
def _init_prometheus_metrics(self) -> None:
|
|
25
|
+
"""Initialize Prometheus metrics if enabled."""
|
|
26
|
+
try:
|
|
27
|
+
from prometheus_client import Counter, Histogram, Gauge
|
|
28
|
+
|
|
29
|
+
# Tool execution metrics
|
|
30
|
+
self._metrics["tool_executions"] = Counter(
|
|
31
|
+
"golf_tool_executions_total",
|
|
32
|
+
"Total number of tool executions",
|
|
33
|
+
["tool_name", "status"],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
self._metrics["tool_duration"] = Histogram(
|
|
37
|
+
"golf_tool_duration_seconds",
|
|
38
|
+
"Tool execution duration in seconds",
|
|
39
|
+
["tool_name"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# HTTP request metrics
|
|
43
|
+
self._metrics["http_requests"] = Counter(
|
|
44
|
+
"golf_http_requests_total",
|
|
45
|
+
"Total number of HTTP requests",
|
|
46
|
+
["method", "status_code", "path"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self._metrics["http_duration"] = Histogram(
|
|
50
|
+
"golf_http_request_duration_seconds",
|
|
51
|
+
"HTTP request duration in seconds",
|
|
52
|
+
["method", "path"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Resource access metrics
|
|
56
|
+
self._metrics["resource_reads"] = Counter(
|
|
57
|
+
"golf_resource_reads_total",
|
|
58
|
+
"Total number of resource reads",
|
|
59
|
+
["resource_uri"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Prompt generation metrics
|
|
63
|
+
self._metrics["prompt_generations"] = Counter(
|
|
64
|
+
"golf_prompt_generations_total",
|
|
65
|
+
"Total number of prompt generations",
|
|
66
|
+
["prompt_name"],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Error metrics
|
|
70
|
+
self._metrics["errors"] = Counter(
|
|
71
|
+
"golf_errors_total",
|
|
72
|
+
"Total number of errors",
|
|
73
|
+
["component_type", "error_type"],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Session metrics
|
|
77
|
+
self._metrics["sessions_total"] = Counter(
|
|
78
|
+
"golf_sessions_total", "Total number of sessions created"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self._metrics["session_duration"] = Histogram(
|
|
82
|
+
"golf_session_duration_seconds", "Session duration in seconds"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# System metrics
|
|
86
|
+
self._metrics["uptime"] = Gauge(
|
|
87
|
+
"golf_uptime_seconds", "Server uptime in seconds"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
except ImportError:
|
|
91
|
+
# Prometheus client not available, disable metrics
|
|
92
|
+
self.enabled = False
|
|
93
|
+
|
|
94
|
+
def increment_tool_execution(self, tool_name: str, status: str) -> None:
|
|
95
|
+
"""Record a tool execution.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
tool_name: Name of the tool that was executed
|
|
99
|
+
status: Execution status ('success' or 'error')
|
|
100
|
+
"""
|
|
101
|
+
if not self.enabled or "tool_executions" not in self._metrics:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
self._metrics["tool_executions"].labels(
|
|
105
|
+
tool_name=tool_name, status=status
|
|
106
|
+
).inc()
|
|
107
|
+
|
|
108
|
+
def record_tool_duration(self, tool_name: str, duration: float) -> None:
|
|
109
|
+
"""Record tool execution duration.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
tool_name: Name of the tool
|
|
113
|
+
duration: Execution duration in seconds
|
|
114
|
+
"""
|
|
115
|
+
if not self.enabled or "tool_duration" not in self._metrics:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
self._metrics["tool_duration"].labels(tool_name=tool_name).observe(duration)
|
|
119
|
+
|
|
120
|
+
def increment_http_request(self, method: str, status_code: int, path: str) -> None:
|
|
121
|
+
"""Record an HTTP request.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
method: HTTP method (GET, POST, etc.)
|
|
125
|
+
status_code: HTTP status code
|
|
126
|
+
path: Request path
|
|
127
|
+
"""
|
|
128
|
+
if not self.enabled or "http_requests" not in self._metrics:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
self._metrics["http_requests"].labels(
|
|
132
|
+
method=method, status_code=str(status_code), path=path
|
|
133
|
+
).inc()
|
|
134
|
+
|
|
135
|
+
def record_http_duration(self, method: str, path: str, duration: float) -> None:
|
|
136
|
+
"""Record HTTP request duration.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
method: HTTP method
|
|
140
|
+
path: Request path
|
|
141
|
+
duration: Request duration in seconds
|
|
142
|
+
"""
|
|
143
|
+
if not self.enabled or "http_duration" not in self._metrics:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
self._metrics["http_duration"].labels(method=method, path=path).observe(
|
|
147
|
+
duration
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def increment_resource_read(self, resource_uri: str) -> None:
|
|
151
|
+
"""Record a resource read.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
resource_uri: URI of the resource that was read
|
|
155
|
+
"""
|
|
156
|
+
if not self.enabled or "resource_reads" not in self._metrics:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
self._metrics["resource_reads"].labels(resource_uri=resource_uri).inc()
|
|
160
|
+
|
|
161
|
+
def increment_prompt_generation(self, prompt_name: str) -> None:
|
|
162
|
+
"""Record a prompt generation.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
prompt_name: Name of the prompt that was generated
|
|
166
|
+
"""
|
|
167
|
+
if not self.enabled or "prompt_generations" not in self._metrics:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
self._metrics["prompt_generations"].labels(prompt_name=prompt_name).inc()
|
|
171
|
+
|
|
172
|
+
def increment_error(self, component_type: str, error_type: str) -> None:
|
|
173
|
+
"""Record an error.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
component_type: Type of component ('tool', 'resource', 'prompt', 'http')
|
|
177
|
+
error_type: Type of error ('timeout', 'auth_error', 'validation_error', etc.)
|
|
178
|
+
"""
|
|
179
|
+
if not self.enabled or "errors" not in self._metrics:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
self._metrics["errors"].labels(
|
|
183
|
+
component_type=component_type, error_type=error_type
|
|
184
|
+
).inc()
|
|
185
|
+
|
|
186
|
+
def increment_session(self) -> None:
|
|
187
|
+
"""Record a new session."""
|
|
188
|
+
if not self.enabled or "sessions_total" not in self._metrics:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
self._metrics["sessions_total"].inc()
|
|
192
|
+
|
|
193
|
+
def record_session_duration(self, duration: float) -> None:
|
|
194
|
+
"""Record session duration.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
duration: Session duration in seconds
|
|
198
|
+
"""
|
|
199
|
+
if not self.enabled or "session_duration" not in self._metrics:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
self._metrics["session_duration"].observe(duration)
|
|
203
|
+
|
|
204
|
+
def set_uptime(self, seconds: float) -> None:
|
|
205
|
+
"""Set the server uptime.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
seconds: Server uptime in seconds
|
|
209
|
+
"""
|
|
210
|
+
if not self.enabled or "uptime" not in self._metrics:
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
self._metrics["uptime"].set(seconds)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def init_metrics_collector(enabled: bool = False) -> MetricsCollector:
|
|
217
|
+
"""Initialize the global metrics collector.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
enabled: Whether to enable metrics collection
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The initialized metrics collector
|
|
224
|
+
"""
|
|
225
|
+
global _metrics_collector
|
|
226
|
+
_metrics_collector = MetricsCollector(enabled=enabled)
|
|
227
|
+
return _metrics_collector
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_metrics_collector() -> MetricsCollector:
|
|
231
|
+
"""Get the global metrics collector instance.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The metrics collector, or a disabled one if not initialized
|
|
235
|
+
"""
|
|
236
|
+
global _metrics_collector
|
|
237
|
+
if _metrics_collector is None:
|
|
238
|
+
_metrics_collector = MetricsCollector(enabled=False)
|
|
239
|
+
return _metrics_collector
|
golf/metrics/registry.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Metrics registry for Golf MCP servers."""
|
|
2
|
+
|
|
3
|
+
from golf.metrics.collector import init_metrics_collector
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def init_metrics(enabled: bool = False) -> None:
|
|
7
|
+
"""Initialize the metrics system.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
enabled: Whether to enable metrics collection
|
|
11
|
+
"""
|
|
12
|
+
init_metrics_collector(enabled=enabled)
|
|
@@ -4,9 +4,11 @@ import asyncio
|
|
|
4
4
|
import functools
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
import time
|
|
7
8
|
from collections.abc import Callable
|
|
8
9
|
from contextlib import asynccontextmanager
|
|
9
10
|
from typing import TypeVar
|
|
11
|
+
from collections import OrderedDict
|
|
10
12
|
|
|
11
13
|
from opentelemetry import baggage, trace
|
|
12
14
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
@@ -15,6 +17,9 @@ from opentelemetry.sdk.trace import TracerProvider
|
|
|
15
17
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
16
18
|
from opentelemetry.trace import Status, StatusCode
|
|
17
19
|
|
|
20
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
21
|
+
from starlette.requests import Request
|
|
22
|
+
|
|
18
23
|
T = TypeVar("T")
|
|
19
24
|
|
|
20
25
|
# Global tracer instance
|
|
@@ -163,6 +168,11 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
|
|
|
163
168
|
|
|
164
169
|
@functools.wraps(func)
|
|
165
170
|
async def async_wrapper(*args, **kwargs):
|
|
171
|
+
# Record metrics timing
|
|
172
|
+
import time
|
|
173
|
+
|
|
174
|
+
start_time = time.time()
|
|
175
|
+
|
|
166
176
|
# Create a more descriptive span name
|
|
167
177
|
span_name = f"mcp.tool.{tool_name}.execute"
|
|
168
178
|
|
|
@@ -205,45 +215,6 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
|
|
|
205
215
|
if session_id_from_baggage:
|
|
206
216
|
span.set_attribute("mcp.session.id", session_id_from_baggage)
|
|
207
217
|
|
|
208
|
-
# Add tool arguments as span attributes (be careful with sensitive data)
|
|
209
|
-
for i, arg in enumerate(args):
|
|
210
|
-
if isinstance(arg, str | int | float | bool) or arg is None:
|
|
211
|
-
span.set_attribute(f"mcp.tool.arg.{i}", str(arg))
|
|
212
|
-
elif hasattr(arg, "__dict__"):
|
|
213
|
-
# For objects, just record the type
|
|
214
|
-
span.set_attribute(f"mcp.tool.arg.{i}.type", type(arg).__name__)
|
|
215
|
-
|
|
216
|
-
# Add named arguments with better naming
|
|
217
|
-
for key, value in kwargs.items():
|
|
218
|
-
if key != "ctx":
|
|
219
|
-
if value is None:
|
|
220
|
-
span.set_attribute(f"mcp.tool.input.{key}", "null")
|
|
221
|
-
elif isinstance(value, str | int | float | bool):
|
|
222
|
-
span.set_attribute(f"mcp.tool.input.{key}", str(value))
|
|
223
|
-
elif isinstance(value, list | tuple):
|
|
224
|
-
span.set_attribute(f"mcp.tool.input.{key}.count", len(value))
|
|
225
|
-
span.set_attribute(f"mcp.tool.input.{key}.type", "array")
|
|
226
|
-
elif isinstance(value, dict):
|
|
227
|
-
span.set_attribute(f"mcp.tool.input.{key}.count", len(value))
|
|
228
|
-
span.set_attribute(f"mcp.tool.input.{key}.type", "object")
|
|
229
|
-
# Only show first few keys to avoid exceeding attribute limits
|
|
230
|
-
if len(value) > 0 and len(value) <= 5:
|
|
231
|
-
keys_list = list(value.keys())[:5]
|
|
232
|
-
# Limit key length and join
|
|
233
|
-
truncated_keys = [
|
|
234
|
-
str(k)[:20] + "..." if len(str(k)) > 20 else str(k)
|
|
235
|
-
for k in keys_list
|
|
236
|
-
]
|
|
237
|
-
span.set_attribute(
|
|
238
|
-
f"mcp.tool.input.{key}.sample_keys",
|
|
239
|
-
",".join(truncated_keys),
|
|
240
|
-
)
|
|
241
|
-
else:
|
|
242
|
-
# For other types, at least record the type
|
|
243
|
-
span.set_attribute(
|
|
244
|
-
f"mcp.tool.input.{key}.type", type(value).__name__
|
|
245
|
-
)
|
|
246
|
-
|
|
247
218
|
# Add event for tool execution start
|
|
248
219
|
span.add_event("tool.execution.started", {"tool.name": tool_name})
|
|
249
220
|
|
|
@@ -254,6 +225,19 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
|
|
|
254
225
|
# Add event for successful completion
|
|
255
226
|
span.add_event("tool.execution.completed", {"tool.name": tool_name})
|
|
256
227
|
|
|
228
|
+
# Record metrics for successful execution
|
|
229
|
+
try:
|
|
230
|
+
from golf.metrics import get_metrics_collector
|
|
231
|
+
|
|
232
|
+
metrics_collector = get_metrics_collector()
|
|
233
|
+
metrics_collector.increment_tool_execution(tool_name, "success")
|
|
234
|
+
metrics_collector.record_tool_duration(
|
|
235
|
+
tool_name, time.time() - start_time
|
|
236
|
+
)
|
|
237
|
+
except ImportError:
|
|
238
|
+
# Metrics not available, continue without metrics
|
|
239
|
+
pass
|
|
240
|
+
|
|
257
241
|
# Capture result metadata with better structure
|
|
258
242
|
if result is not None:
|
|
259
243
|
if isinstance(result, str | int | float | bool):
|
|
@@ -298,10 +282,27 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
|
|
|
298
282
|
"error.message": str(e),
|
|
299
283
|
},
|
|
300
284
|
)
|
|
285
|
+
|
|
286
|
+
# Record metrics for failed execution
|
|
287
|
+
try:
|
|
288
|
+
from golf.metrics import get_metrics_collector
|
|
289
|
+
|
|
290
|
+
metrics_collector = get_metrics_collector()
|
|
291
|
+
metrics_collector.increment_tool_execution(tool_name, "error")
|
|
292
|
+
metrics_collector.increment_error("tool", type(e).__name__)
|
|
293
|
+
except ImportError:
|
|
294
|
+
# Metrics not available, continue without metrics
|
|
295
|
+
pass
|
|
296
|
+
|
|
301
297
|
raise
|
|
302
298
|
|
|
303
299
|
@functools.wraps(func)
|
|
304
300
|
def sync_wrapper(*args, **kwargs):
|
|
301
|
+
# Record metrics timing
|
|
302
|
+
import time
|
|
303
|
+
|
|
304
|
+
start_time = time.time()
|
|
305
|
+
|
|
305
306
|
# Create a more descriptive span name
|
|
306
307
|
span_name = f"mcp.tool.{tool_name}.execute"
|
|
307
308
|
|
|
@@ -344,45 +345,6 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
|
|
|
344
345
|
if session_id_from_baggage:
|
|
345
346
|
span.set_attribute("mcp.session.id", session_id_from_baggage)
|
|
346
347
|
|
|
347
|
-
# Add tool arguments as span attributes (be careful with sensitive data)
|
|
348
|
-
for i, arg in enumerate(args):
|
|
349
|
-
if isinstance(arg, str | int | float | bool) or arg is None:
|
|
350
|
-
span.set_attribute(f"mcp.tool.arg.{i}", str(arg))
|
|
351
|
-
elif hasattr(arg, "__dict__"):
|
|
352
|
-
# For objects, just record the type
|
|
353
|
-
span.set_attribute(f"mcp.tool.arg.{i}.type", type(arg).__name__)
|
|
354
|
-
|
|
355
|
-
# Add named arguments with better naming
|
|
356
|
-
for key, value in kwargs.items():
|
|
357
|
-
if key != "ctx":
|
|
358
|
-
if value is None:
|
|
359
|
-
span.set_attribute(f"mcp.tool.input.{key}", "null")
|
|
360
|
-
elif isinstance(value, str | int | float | bool):
|
|
361
|
-
span.set_attribute(f"mcp.tool.input.{key}", str(value))
|
|
362
|
-
elif isinstance(value, list | tuple):
|
|
363
|
-
span.set_attribute(f"mcp.tool.input.{key}.count", len(value))
|
|
364
|
-
span.set_attribute(f"mcp.tool.input.{key}.type", "array")
|
|
365
|
-
elif isinstance(value, dict):
|
|
366
|
-
span.set_attribute(f"mcp.tool.input.{key}.count", len(value))
|
|
367
|
-
span.set_attribute(f"mcp.tool.input.{key}.type", "object")
|
|
368
|
-
# Only show first few keys to avoid exceeding attribute limits
|
|
369
|
-
if len(value) > 0 and len(value) <= 5:
|
|
370
|
-
keys_list = list(value.keys())[:5]
|
|
371
|
-
# Limit key length and join
|
|
372
|
-
truncated_keys = [
|
|
373
|
-
str(k)[:20] + "..." if len(str(k)) > 20 else str(k)
|
|
374
|
-
for k in keys_list
|
|
375
|
-
]
|
|
376
|
-
span.set_attribute(
|
|
377
|
-
f"mcp.tool.input.{key}.sample_keys",
|
|
378
|
-
",".join(truncated_keys),
|
|
379
|
-
)
|
|
380
|
-
else:
|
|
381
|
-
# For other types, at least record the type
|
|
382
|
-
span.set_attribute(
|
|
383
|
-
f"mcp.tool.input.{key}.type", type(value).__name__
|
|
384
|
-
)
|
|
385
|
-
|
|
386
348
|
# Add event for tool execution start
|
|
387
349
|
span.add_event("tool.execution.started", {"tool.name": tool_name})
|
|
388
350
|
|
|
@@ -393,6 +355,19 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
|
|
|
393
355
|
# Add event for successful completion
|
|
394
356
|
span.add_event("tool.execution.completed", {"tool.name": tool_name})
|
|
395
357
|
|
|
358
|
+
# Record metrics for successful execution
|
|
359
|
+
try:
|
|
360
|
+
from golf.metrics import get_metrics_collector
|
|
361
|
+
|
|
362
|
+
metrics_collector = get_metrics_collector()
|
|
363
|
+
metrics_collector.increment_tool_execution(tool_name, "success")
|
|
364
|
+
metrics_collector.record_tool_duration(
|
|
365
|
+
tool_name, time.time() - start_time
|
|
366
|
+
)
|
|
367
|
+
except ImportError:
|
|
368
|
+
# Metrics not available, continue without metrics
|
|
369
|
+
pass
|
|
370
|
+
|
|
396
371
|
# Capture result metadata with better structure
|
|
397
372
|
if result is not None:
|
|
398
373
|
if isinstance(result, str | int | float | bool):
|
|
@@ -437,6 +412,18 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
|
|
|
437
412
|
"error.message": str(e),
|
|
438
413
|
},
|
|
439
414
|
)
|
|
415
|
+
|
|
416
|
+
# Record metrics for failed execution
|
|
417
|
+
try:
|
|
418
|
+
from golf.metrics import get_metrics_collector
|
|
419
|
+
|
|
420
|
+
metrics_collector = get_metrics_collector()
|
|
421
|
+
metrics_collector.increment_tool_execution(tool_name, "error")
|
|
422
|
+
metrics_collector.increment_error("tool", type(e).__name__)
|
|
423
|
+
except ImportError:
|
|
424
|
+
# Metrics not available, continue without metrics
|
|
425
|
+
pass
|
|
426
|
+
|
|
440
427
|
raise
|
|
441
428
|
|
|
442
429
|
# Return appropriate wrapper based on function type
|
|
@@ -683,16 +670,6 @@ def instrument_prompt(func: Callable[..., T], prompt_name: str) -> Callable[...,
|
|
|
683
670
|
if session_id_from_baggage:
|
|
684
671
|
span.set_attribute("mcp.session.id", session_id_from_baggage)
|
|
685
672
|
|
|
686
|
-
# Add prompt arguments
|
|
687
|
-
for key, value in kwargs.items():
|
|
688
|
-
if key != "ctx":
|
|
689
|
-
if isinstance(value, str | int | float | bool) or value is None:
|
|
690
|
-
span.set_attribute(f"mcp.prompt.arg.{key}", str(value))
|
|
691
|
-
else:
|
|
692
|
-
span.set_attribute(
|
|
693
|
-
f"mcp.prompt.arg.{key}.type", type(value).__name__
|
|
694
|
-
)
|
|
695
|
-
|
|
696
673
|
# Add event for prompt generation start
|
|
697
674
|
span.add_event("prompt.generation.started", {"prompt.name": prompt_name})
|
|
698
675
|
|
|
@@ -787,16 +764,6 @@ def instrument_prompt(func: Callable[..., T], prompt_name: str) -> Callable[...,
|
|
|
787
764
|
if session_id_from_baggage:
|
|
788
765
|
span.set_attribute("mcp.session.id", session_id_from_baggage)
|
|
789
766
|
|
|
790
|
-
# Add prompt arguments
|
|
791
|
-
for key, value in kwargs.items():
|
|
792
|
-
if key != "ctx":
|
|
793
|
-
if isinstance(value, str | int | float | bool) or value is None:
|
|
794
|
-
span.set_attribute(f"mcp.prompt.arg.{key}", str(value))
|
|
795
|
-
else:
|
|
796
|
-
span.set_attribute(
|
|
797
|
-
f"mcp.prompt.arg.{key}.type", type(value).__name__
|
|
798
|
-
)
|
|
799
|
-
|
|
800
767
|
# Add event for prompt generation start
|
|
801
768
|
span.add_event("prompt.generation.started", {"prompt.name": prompt_name})
|
|
802
769
|
|
|
@@ -859,6 +826,238 @@ def instrument_prompt(func: Callable[..., T], prompt_name: str) -> Callable[...,
|
|
|
859
826
|
return sync_wrapper
|
|
860
827
|
|
|
861
828
|
|
|
829
|
+
# Add the BoundedSessionTracker class before SessionTracingMiddleware
|
|
830
|
+
class BoundedSessionTracker:
|
|
831
|
+
"""Memory-safe session tracker with automatic expiration."""
|
|
832
|
+
|
|
833
|
+
def __init__(self, max_sessions: int = 1000, session_ttl: int = 3600):
|
|
834
|
+
self.max_sessions = max_sessions
|
|
835
|
+
self.session_ttl = session_ttl
|
|
836
|
+
self.sessions: OrderedDict[str, float] = OrderedDict()
|
|
837
|
+
self.last_cleanup = time.time()
|
|
838
|
+
|
|
839
|
+
def track_session(self, session_id: str) -> bool:
|
|
840
|
+
"""Track a session, returns True if it's new."""
|
|
841
|
+
current_time = time.time()
|
|
842
|
+
|
|
843
|
+
# Periodic cleanup (every 5 minutes)
|
|
844
|
+
if current_time - self.last_cleanup > 300:
|
|
845
|
+
self._cleanup_expired(current_time)
|
|
846
|
+
self.last_cleanup = current_time
|
|
847
|
+
|
|
848
|
+
# Check if session exists and is still valid
|
|
849
|
+
if session_id in self.sessions:
|
|
850
|
+
# Move to end (mark as recently used)
|
|
851
|
+
self.sessions.move_to_end(session_id)
|
|
852
|
+
return False
|
|
853
|
+
|
|
854
|
+
# New session
|
|
855
|
+
self.sessions[session_id] = current_time
|
|
856
|
+
|
|
857
|
+
# Enforce max size
|
|
858
|
+
while len(self.sessions) > self.max_sessions:
|
|
859
|
+
self.sessions.popitem(last=False) # Remove oldest
|
|
860
|
+
|
|
861
|
+
return True
|
|
862
|
+
|
|
863
|
+
def _cleanup_expired(self, current_time: float):
|
|
864
|
+
"""Remove expired sessions."""
|
|
865
|
+
expired = [
|
|
866
|
+
sid
|
|
867
|
+
for sid, timestamp in self.sessions.items()
|
|
868
|
+
if current_time - timestamp > self.session_ttl
|
|
869
|
+
]
|
|
870
|
+
for sid in expired:
|
|
871
|
+
del self.sessions[sid]
|
|
872
|
+
|
|
873
|
+
def get_active_session_count(self) -> int:
|
|
874
|
+
return len(self.sessions)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
class SessionTracingMiddleware(BaseHTTPMiddleware):
|
|
878
|
+
def __init__(self, app):
|
|
879
|
+
super().__init__(app)
|
|
880
|
+
# Use memory-safe session tracker instead of unbounded collections
|
|
881
|
+
self.session_tracker = BoundedSessionTracker(
|
|
882
|
+
max_sessions=1000, session_ttl=3600
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
async def dispatch(self, request: Request, call_next):
|
|
886
|
+
# Record HTTP request timing
|
|
887
|
+
import time
|
|
888
|
+
|
|
889
|
+
start_time = time.time()
|
|
890
|
+
|
|
891
|
+
# Extract session ID from query params or headers
|
|
892
|
+
session_id = request.query_params.get("session_id")
|
|
893
|
+
if not session_id:
|
|
894
|
+
# Check headers as fallback
|
|
895
|
+
session_id = request.headers.get("x-session-id")
|
|
896
|
+
|
|
897
|
+
# Track session metrics using memory-safe tracker
|
|
898
|
+
if session_id:
|
|
899
|
+
is_new_session = self.session_tracker.track_session(session_id)
|
|
900
|
+
|
|
901
|
+
if is_new_session:
|
|
902
|
+
try:
|
|
903
|
+
from golf.metrics import get_metrics_collector
|
|
904
|
+
|
|
905
|
+
metrics_collector = get_metrics_collector()
|
|
906
|
+
metrics_collector.increment_session()
|
|
907
|
+
except ImportError:
|
|
908
|
+
pass
|
|
909
|
+
else:
|
|
910
|
+
# Record session duration for existing sessions
|
|
911
|
+
try:
|
|
912
|
+
from golf.metrics import get_metrics_collector
|
|
913
|
+
|
|
914
|
+
metrics_collector = get_metrics_collector()
|
|
915
|
+
# Use a default duration since we don't track exact start times anymore
|
|
916
|
+
# This is less precise but memory-safe
|
|
917
|
+
metrics_collector.record_session_duration(300.0) # 5 min default
|
|
918
|
+
except ImportError:
|
|
919
|
+
pass
|
|
920
|
+
|
|
921
|
+
# Create a descriptive span name based on the request
|
|
922
|
+
method = request.method
|
|
923
|
+
path = request.url.path
|
|
924
|
+
|
|
925
|
+
# Determine the operation type from the path
|
|
926
|
+
operation_type = "unknown"
|
|
927
|
+
if "/mcp" in path:
|
|
928
|
+
operation_type = "mcp.request"
|
|
929
|
+
elif "/sse" in path:
|
|
930
|
+
operation_type = "sse.stream"
|
|
931
|
+
elif "/auth" in path:
|
|
932
|
+
operation_type = "auth"
|
|
933
|
+
|
|
934
|
+
span_name = f"{operation_type}.{method.lower()}"
|
|
935
|
+
|
|
936
|
+
tracer = get_tracer()
|
|
937
|
+
with tracer.start_as_current_span(span_name) as span:
|
|
938
|
+
# Add comprehensive HTTP attributes
|
|
939
|
+
span.set_attribute("http.method", method)
|
|
940
|
+
span.set_attribute("http.url", str(request.url))
|
|
941
|
+
span.set_attribute("http.scheme", request.url.scheme)
|
|
942
|
+
span.set_attribute("http.host", request.url.hostname or "unknown")
|
|
943
|
+
span.set_attribute("http.target", path)
|
|
944
|
+
span.set_attribute(
|
|
945
|
+
"http.user_agent", request.headers.get("user-agent", "unknown")
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
# Add session tracking
|
|
949
|
+
if session_id:
|
|
950
|
+
span.set_attribute("mcp.session.id", session_id)
|
|
951
|
+
span.set_attribute(
|
|
952
|
+
"mcp.session.active_count",
|
|
953
|
+
self.session_tracker.get_active_session_count(),
|
|
954
|
+
)
|
|
955
|
+
# Add to baggage for propagation
|
|
956
|
+
ctx = baggage.set_baggage("mcp.session.id", session_id)
|
|
957
|
+
from opentelemetry import context
|
|
958
|
+
|
|
959
|
+
token = context.attach(ctx)
|
|
960
|
+
else:
|
|
961
|
+
token = None
|
|
962
|
+
|
|
963
|
+
# Add request size if available
|
|
964
|
+
content_length = request.headers.get("content-length")
|
|
965
|
+
if content_length:
|
|
966
|
+
span.set_attribute("http.request.size", int(content_length))
|
|
967
|
+
|
|
968
|
+
# Add event for request start
|
|
969
|
+
span.add_event("http.request.started", {"method": method, "path": path})
|
|
970
|
+
|
|
971
|
+
try:
|
|
972
|
+
response = await call_next(request)
|
|
973
|
+
|
|
974
|
+
# Add response attributes
|
|
975
|
+
span.set_attribute("http.status_code", response.status_code)
|
|
976
|
+
span.set_attribute(
|
|
977
|
+
"http.status_class", f"{response.status_code // 100}xx"
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
# Set span status based on HTTP status
|
|
981
|
+
if response.status_code >= 400:
|
|
982
|
+
span.set_status(
|
|
983
|
+
Status(StatusCode.ERROR, f"HTTP {response.status_code}")
|
|
984
|
+
)
|
|
985
|
+
else:
|
|
986
|
+
span.set_status(Status(StatusCode.OK))
|
|
987
|
+
|
|
988
|
+
# Add event for request completion
|
|
989
|
+
span.add_event(
|
|
990
|
+
"http.request.completed",
|
|
991
|
+
{
|
|
992
|
+
"method": method,
|
|
993
|
+
"path": path,
|
|
994
|
+
"status_code": response.status_code,
|
|
995
|
+
},
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
# Record HTTP request metrics
|
|
999
|
+
try:
|
|
1000
|
+
from golf.metrics import get_metrics_collector
|
|
1001
|
+
|
|
1002
|
+
metrics_collector = get_metrics_collector()
|
|
1003
|
+
|
|
1004
|
+
# Clean up path for metrics (remove query params, normalize)
|
|
1005
|
+
clean_path = path.split("?")[0] # Remove query parameters
|
|
1006
|
+
if clean_path.startswith("/"):
|
|
1007
|
+
clean_path = (
|
|
1008
|
+
clean_path[1:] or "root"
|
|
1009
|
+
) # Remove leading slash, handle root
|
|
1010
|
+
|
|
1011
|
+
metrics_collector.increment_http_request(
|
|
1012
|
+
method, response.status_code, clean_path
|
|
1013
|
+
)
|
|
1014
|
+
metrics_collector.record_http_duration(
|
|
1015
|
+
method, clean_path, time.time() - start_time
|
|
1016
|
+
)
|
|
1017
|
+
except ImportError:
|
|
1018
|
+
# Metrics not available, continue without metrics
|
|
1019
|
+
pass
|
|
1020
|
+
|
|
1021
|
+
return response
|
|
1022
|
+
except Exception as e:
|
|
1023
|
+
span.record_exception(e)
|
|
1024
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
1025
|
+
|
|
1026
|
+
# Add event for error
|
|
1027
|
+
span.add_event(
|
|
1028
|
+
"http.request.error",
|
|
1029
|
+
{
|
|
1030
|
+
"method": method,
|
|
1031
|
+
"path": path,
|
|
1032
|
+
"error.type": type(e).__name__,
|
|
1033
|
+
"error.message": str(e),
|
|
1034
|
+
},
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
# Record HTTP error metrics
|
|
1038
|
+
try:
|
|
1039
|
+
from golf.metrics import get_metrics_collector
|
|
1040
|
+
|
|
1041
|
+
metrics_collector = get_metrics_collector()
|
|
1042
|
+
|
|
1043
|
+
# Clean up path for metrics
|
|
1044
|
+
clean_path = path.split("?")[0]
|
|
1045
|
+
if clean_path.startswith("/"):
|
|
1046
|
+
clean_path = clean_path[1:] or "root"
|
|
1047
|
+
|
|
1048
|
+
metrics_collector.increment_http_request(
|
|
1049
|
+
method, 500, clean_path
|
|
1050
|
+
) # Assume 500 for exceptions
|
|
1051
|
+
metrics_collector.increment_error("http", type(e).__name__)
|
|
1052
|
+
except ImportError:
|
|
1053
|
+
pass
|
|
1054
|
+
|
|
1055
|
+
raise
|
|
1056
|
+
finally:
|
|
1057
|
+
if token:
|
|
1058
|
+
context.detach(token)
|
|
1059
|
+
|
|
1060
|
+
|
|
862
1061
|
@asynccontextmanager
|
|
863
1062
|
async def telemetry_lifespan(mcp_instance):
|
|
864
1063
|
"""Simplified lifespan for telemetry initialization and cleanup."""
|
|
@@ -875,112 +1074,6 @@ async def telemetry_lifespan(mcp_instance):
|
|
|
875
1074
|
|
|
876
1075
|
# Try to add session tracking middleware if possible
|
|
877
1076
|
try:
|
|
878
|
-
from starlette.middleware.base import BaseHTTPMiddleware
|
|
879
|
-
from starlette.requests import Request
|
|
880
|
-
|
|
881
|
-
class SessionTracingMiddleware(BaseHTTPMiddleware):
|
|
882
|
-
async def dispatch(self, request: Request, call_next):
|
|
883
|
-
# Extract session ID from query params or headers
|
|
884
|
-
session_id = request.query_params.get("session_id")
|
|
885
|
-
if not session_id:
|
|
886
|
-
# Check headers as fallback
|
|
887
|
-
session_id = request.headers.get("x-session-id")
|
|
888
|
-
|
|
889
|
-
# Create a descriptive span name based on the request
|
|
890
|
-
method = request.method
|
|
891
|
-
path = request.url.path
|
|
892
|
-
|
|
893
|
-
# Determine the operation type from the path
|
|
894
|
-
operation_type = "unknown"
|
|
895
|
-
if "/mcp" in path:
|
|
896
|
-
operation_type = "mcp.request"
|
|
897
|
-
elif "/sse" in path:
|
|
898
|
-
operation_type = "sse.stream"
|
|
899
|
-
elif "/auth" in path:
|
|
900
|
-
operation_type = "auth"
|
|
901
|
-
|
|
902
|
-
span_name = f"{operation_type}.{method.lower()}"
|
|
903
|
-
|
|
904
|
-
tracer = get_tracer()
|
|
905
|
-
with tracer.start_as_current_span(span_name) as span:
|
|
906
|
-
# Add comprehensive HTTP attributes
|
|
907
|
-
span.set_attribute("http.method", method)
|
|
908
|
-
span.set_attribute("http.url", str(request.url))
|
|
909
|
-
span.set_attribute("http.scheme", request.url.scheme)
|
|
910
|
-
span.set_attribute("http.host", request.url.hostname or "unknown")
|
|
911
|
-
span.set_attribute("http.target", path)
|
|
912
|
-
span.set_attribute(
|
|
913
|
-
"http.user_agent", request.headers.get("user-agent", "unknown")
|
|
914
|
-
)
|
|
915
|
-
|
|
916
|
-
# Add session tracking
|
|
917
|
-
if session_id:
|
|
918
|
-
span.set_attribute("mcp.session.id", session_id)
|
|
919
|
-
# Add to baggage for propagation
|
|
920
|
-
ctx = baggage.set_baggage("mcp.session.id", session_id)
|
|
921
|
-
from opentelemetry import context
|
|
922
|
-
|
|
923
|
-
token = context.attach(ctx)
|
|
924
|
-
else:
|
|
925
|
-
token = None
|
|
926
|
-
|
|
927
|
-
# Add request size if available
|
|
928
|
-
content_length = request.headers.get("content-length")
|
|
929
|
-
if content_length:
|
|
930
|
-
span.set_attribute("http.request.size", int(content_length))
|
|
931
|
-
|
|
932
|
-
# Add event for request start
|
|
933
|
-
span.add_event(
|
|
934
|
-
"http.request.started", {"method": method, "path": path}
|
|
935
|
-
)
|
|
936
|
-
|
|
937
|
-
try:
|
|
938
|
-
response = await call_next(request)
|
|
939
|
-
|
|
940
|
-
# Add response attributes
|
|
941
|
-
span.set_attribute("http.status_code", response.status_code)
|
|
942
|
-
span.set_attribute(
|
|
943
|
-
"http.status_class", f"{response.status_code // 100}xx"
|
|
944
|
-
)
|
|
945
|
-
|
|
946
|
-
# Set span status based on HTTP status
|
|
947
|
-
if response.status_code >= 400:
|
|
948
|
-
span.set_status(
|
|
949
|
-
Status(StatusCode.ERROR, f"HTTP {response.status_code}")
|
|
950
|
-
)
|
|
951
|
-
else:
|
|
952
|
-
span.set_status(Status(StatusCode.OK))
|
|
953
|
-
|
|
954
|
-
# Add event for request completion
|
|
955
|
-
span.add_event(
|
|
956
|
-
"http.request.completed",
|
|
957
|
-
{
|
|
958
|
-
"method": method,
|
|
959
|
-
"path": path,
|
|
960
|
-
"status_code": response.status_code,
|
|
961
|
-
},
|
|
962
|
-
)
|
|
963
|
-
|
|
964
|
-
return response
|
|
965
|
-
except Exception as e:
|
|
966
|
-
span.record_exception(e)
|
|
967
|
-
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
968
|
-
|
|
969
|
-
# Add event for error
|
|
970
|
-
span.add_event(
|
|
971
|
-
"http.request.error",
|
|
972
|
-
{
|
|
973
|
-
"method": method,
|
|
974
|
-
"path": path,
|
|
975
|
-
"error.type": type(e).__name__,
|
|
976
|
-
"error.message": str(e),
|
|
977
|
-
},
|
|
978
|
-
)
|
|
979
|
-
raise
|
|
980
|
-
finally:
|
|
981
|
-
if token:
|
|
982
|
-
context.detach(token)
|
|
983
|
-
|
|
984
1077
|
# Try to add middleware to FastMCP app if it has Starlette app
|
|
985
1078
|
if hasattr(mcp_instance, "app") or hasattr(mcp_instance, "_app"):
|
|
986
1079
|
app = getattr(mcp_instance, "app", getattr(mcp_instance, "_app", None))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: golf-mcp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.19
|
|
4
4
|
Summary: Framework for building MCP servers
|
|
5
5
|
Author-email: Antoni Gmitruk <antoni@golf.dev>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -34,6 +34,8 @@ Requires-Dist: opentelemetry-sdk>=1.33.1; extra == "telemetry"
|
|
|
34
34
|
Requires-Dist: opentelemetry-instrumentation-asgi>=0.40b0; extra == "telemetry"
|
|
35
35
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=0.40b0; extra == "telemetry"
|
|
36
36
|
Requires-Dist: wrapt>=1.17.0; extra == "telemetry"
|
|
37
|
+
Provides-Extra: metrics
|
|
38
|
+
Requires-Dist: prometheus-client>=0.22.1; extra == "metrics"
|
|
37
39
|
Dynamic: license-file
|
|
38
40
|
|
|
39
41
|
<div align="center">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
golf/__init__.py,sha256=
|
|
1
|
+
golf/__init__.py,sha256=cAJAbAh288a9AL-3yxwFzEM1L26izSJ6wma5aiml_9Y,23
|
|
2
2
|
golf/auth/__init__.py,sha256=Rj4yUngJklk6xrDCrxqLTtoDAMzF1HcTvy_l8wREeao,4103
|
|
3
3
|
golf/auth/api_key.py,sha256=LiIraLiH2v7s3yavidaI6BDlAEfK8XnWF15QmaJn9G4,2378
|
|
4
4
|
golf/auth/helpers.py,sha256=ZogdcHM7J2PN6cL6F6OLZ3gyoUR3dwAFDxOJQ2DW_bc,6526
|
|
@@ -11,10 +11,11 @@ golf/commands/build.py,sha256=jhdxB5EwwCC_8PgqdXLUKuBpnycjh0gft3_7EuTo6ro,2319
|
|
|
11
11
|
golf/commands/init.py,sha256=DUAvGqOUapWdF2cgWPscqHRvyOZDiajR0F0Wkn_jm-k,10355
|
|
12
12
|
golf/commands/run.py,sha256=xsiG5LZw4qVt3cRTTfIoWP4Bf4AxNBBJKx0NNfoua40,2884
|
|
13
13
|
golf/core/__init__.py,sha256=4bKeskJ2fPaZqkz2xQScSa3phRLLrmrczwSL632jv-o,52
|
|
14
|
-
golf/core/builder.py,sha256=
|
|
15
|
-
golf/core/builder_auth.py,sha256=
|
|
14
|
+
golf/core/builder.py,sha256=flsXnlwOTUhXFMpmZwoxWgnK_oLj4zpSyqcKuDnXezw,59526
|
|
15
|
+
golf/core/builder_auth.py,sha256=nGgyMTiRAqaNfh1FSvoFe6oTVq9RgfMf9JoFGAv2_do,14050
|
|
16
|
+
golf/core/builder_metrics.py,sha256=j6Gtgd867o46JbDfSNGNsHt1QtV1XHKUJs1z8r4siQM,8830
|
|
16
17
|
golf/core/builder_telemetry.py,sha256=jobFgRSspLQLuitL4ytk6asSUdTqYcDxGY3sTjkrZd4,2654
|
|
17
|
-
golf/core/config.py,sha256=
|
|
18
|
+
golf/core/config.py,sha256=6yPtwzVTJauufEnrfUbxsz69H8jC0Ra427oDaRM0-xE,7397
|
|
18
19
|
golf/core/parser.py,sha256=BQRus1O9zmzSmyavwLVfN8BpYFkbrWUDrgQ7yrYfAKw,42457
|
|
19
20
|
golf/core/platform.py,sha256=Z2yEi6ilmQCLC_uAD_oZdVO0WvkL4tsyw7sx0vHhysI,6440
|
|
20
21
|
golf/core/telemetry.py,sha256=CjZ7urbizaRjyFiVBjfGW8V4PmNCG1_quk3FvbVTcjw,15772
|
|
@@ -46,11 +47,14 @@ golf/examples/basic/tools/hello.py,sha256=s7Soiq9Wn7oKIvA6Hid8UKB14iyR7HZppIbIT4
|
|
|
46
47
|
golf/examples/basic/tools/payments/charge.py,sha256=PIYdFV90hu35H1veLI8ueuYwebzrr5SCTX-x6lxRmU4,1800
|
|
47
48
|
golf/examples/basic/tools/payments/common.py,sha256=hfyuQRIjrQfSqGjyY55W6pZSD5jL6O0geCE0DGx0v10,1302
|
|
48
49
|
golf/examples/basic/tools/payments/refund.py,sha256=Qpl4GWvUw-L06oGQMbBzG8pikfCWfBCFcpkRiDOzmyQ,1607
|
|
50
|
+
golf/metrics/__init__.py,sha256=O91y-hj_E9R06gqV8pDZrzHxOIl-1T415Hj9RvFAp3o,283
|
|
51
|
+
golf/metrics/collector.py,sha256=iyRszP8TAAigyOsUFTGdKN8Xeob36LhUvXW9tntJrbA,7617
|
|
52
|
+
golf/metrics/registry.py,sha256=mXQE4Pwf3PopGYjcUu4eGgPDAe085YWcsvcvWk0ny8Q,310
|
|
49
53
|
golf/telemetry/__init__.py,sha256=ESGCg5HKwTCIfID1e_K7EE0bOWkSzMidlLtdqQgBd0w,396
|
|
50
|
-
golf/telemetry/instrumentation.py,sha256=
|
|
51
|
-
golf_mcp-0.1.
|
|
52
|
-
golf_mcp-0.1.
|
|
53
|
-
golf_mcp-0.1.
|
|
54
|
-
golf_mcp-0.1.
|
|
55
|
-
golf_mcp-0.1.
|
|
56
|
-
golf_mcp-0.1.
|
|
54
|
+
golf/telemetry/instrumentation.py,sha256=8n69nYUzsLCvItyYVe7TbxiyfYjgJHqC4sV7GgwL7QI,44652
|
|
55
|
+
golf_mcp-0.1.19.dist-info/licenses/LICENSE,sha256=5_j2f6fTJmvfmUewzElhkpAaXg2grVoxKouOA8ihV6E,11348
|
|
56
|
+
golf_mcp-0.1.19.dist-info/METADATA,sha256=VWwqQwy6gSijjJcjA2A2euWf53gHpZ3KhFRwK8yrE4U,12956
|
|
57
|
+
golf_mcp-0.1.19.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
58
|
+
golf_mcp-0.1.19.dist-info/entry_points.txt,sha256=5y7rHYM8jGpU-nfwdknCm5XsApLulqsnA37MO6BUTYg,43
|
|
59
|
+
golf_mcp-0.1.19.dist-info/top_level.txt,sha256=BQToHcBUufdyhp9ONGMIvPE40jMEtmI20lYaKb4hxOg,5
|
|
60
|
+
golf_mcp-0.1.19.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|