golf-mcp 0.1.17__py3-none-any.whl → 0.1.18__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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.17"
1
+ __version__ = "0.1.18"
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.
@@ -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
@@ -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)
@@ -163,6 +163,11 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
163
163
 
164
164
  @functools.wraps(func)
165
165
  async def async_wrapper(*args, **kwargs):
166
+ # Record metrics timing
167
+ import time
168
+
169
+ start_time = time.time()
170
+
166
171
  # Create a more descriptive span name
167
172
  span_name = f"mcp.tool.{tool_name}.execute"
168
173
 
@@ -205,45 +210,6 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
205
210
  if session_id_from_baggage:
206
211
  span.set_attribute("mcp.session.id", session_id_from_baggage)
207
212
 
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
213
  # Add event for tool execution start
248
214
  span.add_event("tool.execution.started", {"tool.name": tool_name})
249
215
 
@@ -254,6 +220,19 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
254
220
  # Add event for successful completion
255
221
  span.add_event("tool.execution.completed", {"tool.name": tool_name})
256
222
 
223
+ # Record metrics for successful execution
224
+ try:
225
+ from golf.metrics import get_metrics_collector
226
+
227
+ metrics_collector = get_metrics_collector()
228
+ metrics_collector.increment_tool_execution(tool_name, "success")
229
+ metrics_collector.record_tool_duration(
230
+ tool_name, time.time() - start_time
231
+ )
232
+ except ImportError:
233
+ # Metrics not available, continue without metrics
234
+ pass
235
+
257
236
  # Capture result metadata with better structure
258
237
  if result is not None:
259
238
  if isinstance(result, str | int | float | bool):
@@ -298,10 +277,27 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
298
277
  "error.message": str(e),
299
278
  },
300
279
  )
280
+
281
+ # Record metrics for failed execution
282
+ try:
283
+ from golf.metrics import get_metrics_collector
284
+
285
+ metrics_collector = get_metrics_collector()
286
+ metrics_collector.increment_tool_execution(tool_name, "error")
287
+ metrics_collector.increment_error("tool", type(e).__name__)
288
+ except ImportError:
289
+ # Metrics not available, continue without metrics
290
+ pass
291
+
301
292
  raise
302
293
 
303
294
  @functools.wraps(func)
304
295
  def sync_wrapper(*args, **kwargs):
296
+ # Record metrics timing
297
+ import time
298
+
299
+ start_time = time.time()
300
+
305
301
  # Create a more descriptive span name
306
302
  span_name = f"mcp.tool.{tool_name}.execute"
307
303
 
@@ -344,45 +340,6 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
344
340
  if session_id_from_baggage:
345
341
  span.set_attribute("mcp.session.id", session_id_from_baggage)
346
342
 
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
343
  # Add event for tool execution start
387
344
  span.add_event("tool.execution.started", {"tool.name": tool_name})
388
345
 
@@ -393,6 +350,19 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
393
350
  # Add event for successful completion
394
351
  span.add_event("tool.execution.completed", {"tool.name": tool_name})
395
352
 
353
+ # Record metrics for successful execution
354
+ try:
355
+ from golf.metrics import get_metrics_collector
356
+
357
+ metrics_collector = get_metrics_collector()
358
+ metrics_collector.increment_tool_execution(tool_name, "success")
359
+ metrics_collector.record_tool_duration(
360
+ tool_name, time.time() - start_time
361
+ )
362
+ except ImportError:
363
+ # Metrics not available, continue without metrics
364
+ pass
365
+
396
366
  # Capture result metadata with better structure
397
367
  if result is not None:
398
368
  if isinstance(result, str | int | float | bool):
@@ -437,6 +407,18 @@ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
437
407
  "error.message": str(e),
438
408
  },
439
409
  )
410
+
411
+ # Record metrics for failed execution
412
+ try:
413
+ from golf.metrics import get_metrics_collector
414
+
415
+ metrics_collector = get_metrics_collector()
416
+ metrics_collector.increment_tool_execution(tool_name, "error")
417
+ metrics_collector.increment_error("tool", type(e).__name__)
418
+ except ImportError:
419
+ # Metrics not available, continue without metrics
420
+ pass
421
+
440
422
  raise
441
423
 
442
424
  # Return appropriate wrapper based on function type
@@ -683,16 +665,6 @@ def instrument_prompt(func: Callable[..., T], prompt_name: str) -> Callable[...,
683
665
  if session_id_from_baggage:
684
666
  span.set_attribute("mcp.session.id", session_id_from_baggage)
685
667
 
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
668
  # Add event for prompt generation start
697
669
  span.add_event("prompt.generation.started", {"prompt.name": prompt_name})
698
670
 
@@ -787,16 +759,6 @@ def instrument_prompt(func: Callable[..., T], prompt_name: str) -> Callable[...,
787
759
  if session_id_from_baggage:
788
760
  span.set_attribute("mcp.session.id", session_id_from_baggage)
789
761
 
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
762
  # Add event for prompt generation start
801
763
  span.add_event("prompt.generation.started", {"prompt.name": prompt_name})
802
764
 
@@ -879,13 +841,64 @@ async def telemetry_lifespan(mcp_instance):
879
841
  from starlette.requests import Request
880
842
 
881
843
  class SessionTracingMiddleware(BaseHTTPMiddleware):
844
+ def __init__(self, app):
845
+ super().__init__(app)
846
+ # Track seen sessions to count unique sessions
847
+ self.seen_sessions = set()
848
+ # Track session start times for duration calculation
849
+ self.session_start_times = {}
850
+
882
851
  async def dispatch(self, request: Request, call_next):
852
+ # Record HTTP request timing
853
+ import time
854
+
855
+ start_time = time.time()
856
+
883
857
  # Extract session ID from query params or headers
884
858
  session_id = request.query_params.get("session_id")
885
859
  if not session_id:
886
860
  # Check headers as fallback
887
861
  session_id = request.headers.get("x-session-id")
888
862
 
863
+ # Track session metrics
864
+ if session_id:
865
+ current_time = time.time()
866
+
867
+ # Record new session if we haven't seen this session ID before
868
+ if session_id not in self.seen_sessions:
869
+ self.seen_sessions.add(session_id)
870
+ self.session_start_times[session_id] = current_time
871
+ try:
872
+ from golf.metrics import get_metrics_collector
873
+
874
+ metrics_collector = get_metrics_collector()
875
+ metrics_collector.increment_session()
876
+ except ImportError:
877
+ pass
878
+ else:
879
+ # Update session duration (time since first request)
880
+ if session_id in self.session_start_times:
881
+ duration = (
882
+ current_time - self.session_start_times[session_id]
883
+ )
884
+ try:
885
+ from golf.metrics import get_metrics_collector
886
+
887
+ metrics_collector = get_metrics_collector()
888
+ metrics_collector.record_session_duration(duration)
889
+ except ImportError:
890
+ pass
891
+
892
+ # Clean up old session data periodically
893
+ if len(self.seen_sessions) > 10000:
894
+ # Keep only the most recent 5000 sessions
895
+ recent_sessions = list(self.seen_sessions)[-5000:]
896
+ self.seen_sessions = set(recent_sessions)
897
+ # Clean up start times for removed sessions
898
+ for old_session in list(self.session_start_times.keys()):
899
+ if old_session not in self.seen_sessions:
900
+ self.session_start_times.pop(old_session, None)
901
+
889
902
  # Create a descriptive span name based on the request
890
903
  method = request.method
891
904
  path = request.url.path
@@ -961,6 +974,29 @@ async def telemetry_lifespan(mcp_instance):
961
974
  },
962
975
  )
963
976
 
977
+ # Record HTTP request metrics
978
+ try:
979
+ from golf.metrics import get_metrics_collector
980
+
981
+ metrics_collector = get_metrics_collector()
982
+
983
+ # Clean up path for metrics (remove query params, normalize)
984
+ clean_path = path.split("?")[0] # Remove query parameters
985
+ if clean_path.startswith("/"):
986
+ clean_path = (
987
+ clean_path[1:] or "root"
988
+ ) # Remove leading slash, handle root
989
+
990
+ metrics_collector.increment_http_request(
991
+ method, response.status_code, clean_path
992
+ )
993
+ metrics_collector.record_http_duration(
994
+ method, clean_path, time.time() - start_time
995
+ )
996
+ except ImportError:
997
+ # Metrics not available, continue without metrics
998
+ pass
999
+
964
1000
  return response
965
1001
  except Exception as e:
966
1002
  span.record_exception(e)
@@ -976,6 +1012,25 @@ async def telemetry_lifespan(mcp_instance):
976
1012
  "error.message": str(e),
977
1013
  },
978
1014
  )
1015
+
1016
+ # Record HTTP error metrics
1017
+ try:
1018
+ from golf.metrics import get_metrics_collector
1019
+
1020
+ metrics_collector = get_metrics_collector()
1021
+
1022
+ # Clean up path for metrics
1023
+ clean_path = path.split("?")[0]
1024
+ if clean_path.startswith("/"):
1025
+ clean_path = clean_path[1:] or "root"
1026
+
1027
+ metrics_collector.increment_http_request(
1028
+ method, 500, clean_path
1029
+ ) # Assume 500 for exceptions
1030
+ metrics_collector.increment_error("http", type(e).__name__)
1031
+ except ImportError:
1032
+ pass
1033
+
979
1034
  raise
980
1035
  finally:
981
1036
  if token:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: golf-mcp
3
- Version: 0.1.17
3
+ Version: 0.1.18
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=BzIjnki8Bz3evNWo6bjGxxpLhy_tN9MRYhtM0MnDiWs,23
1
+ golf/__init__.py,sha256=6BiuMUkhwQp6bzUZSF8np8F1NwCltEtK0sPBF__tepU,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=9TCtZIUaywxMsC3FIj3VFFgEA7sfrER5WaM4BfYAUS8,55807
15
- golf/core/builder_auth.py,sha256=6oGw1HsHdtAjfbJKoyakBrFp2v6FgrO1hFuLR9thiY4,13821
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=B_GcPDjf2PuOemZNaFMOEuzqtnCchQaJCQkYAuULUgQ,7184
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=2B0R918-OLpRdyXMTpHuerfc_4osuSi7Lrh9Y2luXfs,43575
51
- golf_mcp-0.1.17.dist-info/licenses/LICENSE,sha256=5_j2f6fTJmvfmUewzElhkpAaXg2grVoxKouOA8ihV6E,11348
52
- golf_mcp-0.1.17.dist-info/METADATA,sha256=-OBF6l8aFo-QYVpnf2c2Uas-8obFK5KYp0EaRvlr_7E,12871
53
- golf_mcp-0.1.17.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
54
- golf_mcp-0.1.17.dist-info/entry_points.txt,sha256=5y7rHYM8jGpU-nfwdknCm5XsApLulqsnA37MO6BUTYg,43
55
- golf_mcp-0.1.17.dist-info/top_level.txt,sha256=BQToHcBUufdyhp9ONGMIvPE40jMEtmI20lYaKb4hxOg,5
56
- golf_mcp-0.1.17.dist-info/RECORD,,
54
+ golf/telemetry/instrumentation.py,sha256=Sn7KBJSCf0Kih9ILEdminR0HggpbOlBKToJhnC4PmZE,44934
55
+ golf_mcp-0.1.18.dist-info/licenses/LICENSE,sha256=5_j2f6fTJmvfmUewzElhkpAaXg2grVoxKouOA8ihV6E,11348
56
+ golf_mcp-0.1.18.dist-info/METADATA,sha256=w8GR9q2Ln0UzWTXRFG6XdA1EzbAVUsF7D0tq1nEMqd4,12956
57
+ golf_mcp-0.1.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
58
+ golf_mcp-0.1.18.dist-info/entry_points.txt,sha256=5y7rHYM8jGpU-nfwdknCm5XsApLulqsnA37MO6BUTYg,43
59
+ golf_mcp-0.1.18.dist-info/top_level.txt,sha256=BQToHcBUufdyhp9ONGMIvPE40jMEtmI20lYaKb4hxOg,5
60
+ golf_mcp-0.1.18.dist-info/RECORD,,