golf-mcp 0.1.7__py3-none-any.whl → 0.1.9__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 +240 -281
- golf/core/builder_auth.py +149 -43
- golf/core/builder_telemetry.py +44 -179
- golf/core/telemetry.py +46 -8
- golf/examples/api_key/.env.example +5 -0
- golf/examples/api_key/golf.json +3 -1
- golf/examples/basic/.env.example +3 -1
- golf/examples/basic/golf.json +3 -1
- golf/telemetry/__init__.py +19 -0
- golf/telemetry/instrumentation.py +540 -0
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/METADATA +41 -2
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/RECORD +17 -14
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/WHEEL +0 -0
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/entry_points.txt +0 -0
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/licenses/LICENSE +0 -0
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/top_level.txt +0 -0
golf/core/builder_auth.py
CHANGED
|
@@ -8,28 +8,34 @@ from golf.auth import get_auth_config
|
|
|
8
8
|
from golf.auth.api_key import get_api_key_config
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def generate_auth_code(server_name: str, host: str = "127.0.0.1", port: int = 3000, https: bool = False) ->
|
|
12
|
-
"""Generate
|
|
13
|
-
|
|
11
|
+
def generate_auth_code(server_name: str, host: str = "127.0.0.1", port: int = 3000, https: bool = False, opentelemetry_enabled: bool = False, transport: str = "streamable-http") -> dict:
|
|
12
|
+
"""Generate authentication components for the FastMCP app.
|
|
13
|
+
|
|
14
|
+
Returns a dictionary with:
|
|
15
|
+
- imports: List of import statements
|
|
16
|
+
- setup_code: Auth setup code (provider configuration, etc.)
|
|
17
|
+
- fastmcp_args: Dict of arguments to add to FastMCP constructor
|
|
18
|
+
- has_auth: Whether auth is configured
|
|
14
19
|
"""
|
|
15
20
|
# Check for API key configuration first
|
|
16
21
|
api_key_config = get_api_key_config()
|
|
17
22
|
if api_key_config:
|
|
18
|
-
return
|
|
23
|
+
return generate_api_key_auth_components(server_name, opentelemetry_enabled, transport)
|
|
19
24
|
|
|
20
25
|
# Otherwise check for OAuth configuration
|
|
21
26
|
original_provider_config, required_scopes_from_config = get_auth_config()
|
|
22
27
|
|
|
23
28
|
if not original_provider_config:
|
|
24
|
-
# If no auth config
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
# If no auth config, return empty components
|
|
30
|
+
return {
|
|
31
|
+
"imports": [],
|
|
32
|
+
"setup_code": [],
|
|
33
|
+
"fastmcp_args": {},
|
|
34
|
+
"has_auth": False
|
|
35
|
+
}
|
|
29
36
|
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
generated_code_lines.extend([
|
|
37
|
+
# Build auth components
|
|
38
|
+
auth_imports = [
|
|
33
39
|
"import os",
|
|
34
40
|
"import sys # For stderr output",
|
|
35
41
|
"from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions",
|
|
@@ -37,11 +43,12 @@ def generate_auth_code(server_name: str, host: str = "127.0.0.1", port: int = 30
|
|
|
37
43
|
"from golf.auth.oauth import GolfOAuthProvider",
|
|
38
44
|
"# get_access_token and create_callback_handler are used by generated auth_routes",
|
|
39
45
|
"from golf.auth import get_access_token, create_callback_handler",
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
setup_code_lines = []
|
|
42
49
|
|
|
43
50
|
# Code to determine runtime server address configuration
|
|
44
|
-
|
|
51
|
+
setup_code_lines.extend([
|
|
45
52
|
"# Determine runtime server address configuration",
|
|
46
53
|
f"runtime_host = os.environ.get('HOST', {repr(host)})",
|
|
47
54
|
f"runtime_port = int(os.environ.get('PORT', {repr(port)}))",
|
|
@@ -57,7 +64,7 @@ def generate_auth_code(server_name: str, host: str = "127.0.0.1", port: int = 30
|
|
|
57
64
|
])
|
|
58
65
|
|
|
59
66
|
# Code to load secrets from environment variables AT RUNTIME in server.py
|
|
60
|
-
|
|
67
|
+
setup_code_lines.extend([
|
|
61
68
|
"# Load secrets from environment variables using names specified in pre_build.py ProviderConfig",
|
|
62
69
|
f"runtime_client_id = os.environ.get({repr(original_provider_config.client_id_env_var)})",
|
|
63
70
|
f"runtime_client_secret = os.environ.get({repr(original_provider_config.client_secret_env_var)})",
|
|
@@ -75,7 +82,7 @@ def generate_auth_code(server_name: str, host: str = "127.0.0.1", port: int = 30
|
|
|
75
82
|
])
|
|
76
83
|
|
|
77
84
|
# Code to instantiate ProviderConfig using runtime-loaded secrets and other baked-in non-secrets
|
|
78
|
-
|
|
85
|
+
setup_code_lines.extend([
|
|
79
86
|
"# Instantiate ProviderConfig with runtime-resolved secrets and other pre-configured values",
|
|
80
87
|
f"provider_config_instance = GolfProviderConfigInternal(", # Use aliased import
|
|
81
88
|
f" provider={repr(original_provider_config.provider)},",
|
|
@@ -101,8 +108,8 @@ def generate_auth_code(server_name: str, host: str = "127.0.0.1", port: int = 30
|
|
|
101
108
|
"",
|
|
102
109
|
])
|
|
103
110
|
|
|
104
|
-
# AuthSettings
|
|
105
|
-
|
|
111
|
+
# AuthSettings creation
|
|
112
|
+
setup_code_lines.extend([
|
|
106
113
|
"# Create auth settings for FastMCP",
|
|
107
114
|
"auth_settings = AuthSettings(",
|
|
108
115
|
" issuer_url=runtime_issuer_url,",
|
|
@@ -114,32 +121,52 @@ def generate_auth_code(server_name: str, host: str = "127.0.0.1", port: int = 30
|
|
|
114
121
|
f" required_scopes={repr(required_scopes_from_config) if required_scopes_from_config else None}",
|
|
115
122
|
")",
|
|
116
123
|
"",
|
|
117
|
-
"# Create FastMCP instance with auth configuration",
|
|
118
|
-
f"mcp = FastMCP({repr(server_name)}, auth_server_provider=auth_provider, auth=auth_settings)"
|
|
119
124
|
])
|
|
120
|
-
|
|
121
|
-
return "\n".join(generated_code_lines)
|
|
122
125
|
|
|
126
|
+
# FastMCP constructor arguments
|
|
127
|
+
fastmcp_args = {
|
|
128
|
+
"auth_server_provider": "auth_provider",
|
|
129
|
+
"auth": "auth_settings"
|
|
130
|
+
}
|
|
123
131
|
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
return {
|
|
133
|
+
"imports": auth_imports,
|
|
134
|
+
"setup_code": setup_code_lines,
|
|
135
|
+
"fastmcp_args": fastmcp_args,
|
|
136
|
+
"has_auth": True
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def generate_api_key_auth_components(server_name: str, opentelemetry_enabled: bool = False, transport: str = "streamable-http") -> dict:
|
|
141
|
+
"""Generate authentication components for API key authentication.
|
|
142
|
+
|
|
143
|
+
Returns a dictionary with:
|
|
144
|
+
- imports: List of import statements
|
|
145
|
+
- setup_code: Auth setup code (middleware setup)
|
|
146
|
+
- fastmcp_args: Dict of arguments to add to FastMCP constructor
|
|
147
|
+
- has_auth: Whether auth is configured
|
|
148
|
+
- post_init_code: Code to run after FastMCP instance is created
|
|
149
|
+
"""
|
|
126
150
|
api_key_config = get_api_key_config()
|
|
127
151
|
if not api_key_config:
|
|
128
|
-
return
|
|
152
|
+
return {
|
|
153
|
+
"imports": [],
|
|
154
|
+
"setup_code": [],
|
|
155
|
+
"fastmcp_args": {},
|
|
156
|
+
"has_auth": False,
|
|
157
|
+
"post_init_code": []
|
|
158
|
+
}
|
|
129
159
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
# Imports
|
|
133
|
-
generated_code_lines.extend([
|
|
160
|
+
auth_imports = [
|
|
134
161
|
"# API key authentication setup",
|
|
135
162
|
"from golf.auth.helpers import set_api_key",
|
|
136
163
|
"from golf.auth.api_key import get_api_key_config",
|
|
137
164
|
"from starlette.middleware.base import BaseHTTPMiddleware",
|
|
138
165
|
"from starlette.requests import Request",
|
|
139
166
|
"from starlette.responses import JSONResponse",
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
setup_code_lines = [
|
|
143
170
|
"# Middleware to extract API key from headers",
|
|
144
171
|
"class ApiKeyMiddleware(BaseHTTPMiddleware):",
|
|
145
172
|
" async def dispatch(self, request: Request, call_next):",
|
|
@@ -148,7 +175,6 @@ def generate_api_key_auth_code(server_name: str) -> str:
|
|
|
148
175
|
" # Extract API key from the configured header",
|
|
149
176
|
" header_name = api_key_config.header_name",
|
|
150
177
|
" header_prefix = api_key_config.header_prefix",
|
|
151
|
-
" is_required = api_key_config.required",
|
|
152
178
|
" ",
|
|
153
179
|
" # Case-insensitive header lookup",
|
|
154
180
|
" api_key = None",
|
|
@@ -157,16 +183,23 @@ def generate_api_key_auth_code(server_name: str) -> str:
|
|
|
157
183
|
" api_key = v",
|
|
158
184
|
" break",
|
|
159
185
|
" ",
|
|
186
|
+
" # Check if API key is required and missing",
|
|
187
|
+
" if api_key_config.required and not api_key:",
|
|
188
|
+
" return JSONResponse(",
|
|
189
|
+
" {'error': 'unauthorized', 'detail': f'Missing required {header_name} header'},",
|
|
190
|
+
" status_code=401,",
|
|
191
|
+
" headers={'WWW-Authenticate': f'{header_name} realm=\"MCP Server\"'}",
|
|
192
|
+
" )",
|
|
193
|
+
" ",
|
|
160
194
|
" # Strip prefix if configured and present",
|
|
161
195
|
" if api_key and header_prefix and api_key.startswith(header_prefix):",
|
|
162
196
|
" api_key = api_key[len(header_prefix):]",
|
|
163
|
-
" ",
|
|
164
|
-
"
|
|
165
|
-
" if is_required and not api_key:",
|
|
197
|
+
" elif api_key and header_prefix and api_key_config.required:",
|
|
198
|
+
" # Has API key but wrong format when required",
|
|
166
199
|
" return JSONResponse(",
|
|
167
|
-
" {
|
|
200
|
+
" {'error': 'unauthorized', 'detail': f'Invalid {header_name} format, expected prefix: {header_prefix}'},",
|
|
168
201
|
" status_code=401,",
|
|
169
|
-
|
|
202
|
+
" headers={'WWW-Authenticate': f'{header_name} realm=\"MCP Server\"'}",
|
|
170
203
|
" )",
|
|
171
204
|
" ",
|
|
172
205
|
" # Store the API key in context for tools to access",
|
|
@@ -176,11 +209,84 @@ def generate_api_key_auth_code(server_name: str) -> str:
|
|
|
176
209
|
" response = await call_next(request)",
|
|
177
210
|
" return response",
|
|
178
211
|
"",
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
# API key auth is handled via middleware, not FastMCP constructor args
|
|
215
|
+
fastmcp_args = {}
|
|
216
|
+
|
|
217
|
+
# Code to run after FastMCP instance is created
|
|
218
|
+
# FastMCP doesn't expose .app directly, so we need to use custom_route
|
|
219
|
+
# to add a middleware-like functionality
|
|
220
|
+
post_init_code = [
|
|
221
|
+
"# API key authentication via custom middleware function",
|
|
222
|
+
"# Since FastMCP doesn't expose .app, we'll use a different approach",
|
|
223
|
+
"import functools",
|
|
224
|
+
"from starlette.responses import JSONResponse",
|
|
225
|
+
"",
|
|
226
|
+
"# Store original method references",
|
|
227
|
+
"_original_call_tool = mcp._mcp_call_tool if hasattr(mcp, '_mcp_call_tool') else None",
|
|
228
|
+
"_original_read_resource = mcp._mcp_read_resource if hasattr(mcp, '_mcp_read_resource') else None",
|
|
229
|
+
"_original_get_prompt = mcp._mcp_get_prompt if hasattr(mcp, '_mcp_get_prompt') else None",
|
|
230
|
+
"",
|
|
231
|
+
"# Wrapper to extract API key before processing",
|
|
232
|
+
"def with_api_key_extraction(original_method):",
|
|
233
|
+
" @functools.wraps(original_method)",
|
|
234
|
+
" async def wrapper(request, *args, **kwargs):",
|
|
235
|
+
" # Extract API key from request headers",
|
|
236
|
+
" api_key_config = get_api_key_config()",
|
|
237
|
+
" if api_key_config and hasattr(request, 'headers'):",
|
|
238
|
+
" header_name = api_key_config.header_name",
|
|
239
|
+
" header_prefix = api_key_config.header_prefix",
|
|
240
|
+
" ",
|
|
241
|
+
" # Case-insensitive header lookup",
|
|
242
|
+
" api_key = None",
|
|
243
|
+
" for k, v in request.headers.items():",
|
|
244
|
+
" if k.lower() == header_name.lower():",
|
|
245
|
+
" api_key = v",
|
|
246
|
+
" break",
|
|
247
|
+
" ",
|
|
248
|
+
" # Check if API key is required and missing",
|
|
249
|
+
" if api_key_config.required and not api_key:",
|
|
250
|
+
" return JSONResponse(",
|
|
251
|
+
" {'error': 'unauthorized', 'detail': f'Missing required {header_name} header'},",
|
|
252
|
+
" status_code=401,",
|
|
253
|
+
" headers={'WWW-Authenticate': f'{header_name} realm=\"MCP Server\"'}",
|
|
254
|
+
" )",
|
|
255
|
+
" ",
|
|
256
|
+
" # Strip prefix if configured and present",
|
|
257
|
+
" if api_key and header_prefix and api_key.startswith(header_prefix):",
|
|
258
|
+
" api_key = api_key[len(header_prefix):]",
|
|
259
|
+
" elif api_key and header_prefix and api_key_config.required:",
|
|
260
|
+
" # Has API key but wrong format when required",
|
|
261
|
+
" return JSONResponse(",
|
|
262
|
+
" {'error': 'unauthorized', 'detail': f'Invalid {header_name} format, expected prefix: {header_prefix}'},",
|
|
263
|
+
" status_code=401,",
|
|
264
|
+
" headers={'WWW-Authenticate': f'{header_name} realm=\"MCP Server\"'}",
|
|
265
|
+
" )",
|
|
266
|
+
" ",
|
|
267
|
+
" # Store the API key in context for tools to access",
|
|
268
|
+
" set_api_key(api_key)",
|
|
269
|
+
" ",
|
|
270
|
+
" # Call the original method",
|
|
271
|
+
" return await original_method(request, *args, **kwargs)",
|
|
272
|
+
" return wrapper",
|
|
273
|
+
"",
|
|
274
|
+
"# Wrap the MCP methods if they exist",
|
|
275
|
+
"if _original_call_tool:",
|
|
276
|
+
" mcp._mcp_call_tool = with_api_key_extraction(_original_call_tool)",
|
|
277
|
+
"if _original_read_resource:",
|
|
278
|
+
" mcp._mcp_read_resource = with_api_key_extraction(_original_read_resource)",
|
|
279
|
+
"if _original_get_prompt:",
|
|
280
|
+
" mcp._mcp_get_prompt = with_api_key_extraction(_original_get_prompt)",
|
|
281
|
+
]
|
|
182
282
|
|
|
183
|
-
return
|
|
283
|
+
return {
|
|
284
|
+
"imports": auth_imports,
|
|
285
|
+
"setup_code": setup_code_lines,
|
|
286
|
+
"fastmcp_args": fastmcp_args,
|
|
287
|
+
"has_auth": True,
|
|
288
|
+
"post_init_code": post_init_code
|
|
289
|
+
}
|
|
184
290
|
|
|
185
291
|
|
|
186
292
|
def generate_auth_routes() -> str:
|
golf/core/builder_telemetry.py
CHANGED
|
@@ -4,194 +4,60 @@ This module provides functions for generating OpenTelemetry initialization
|
|
|
4
4
|
and instrumentation code for FastMCP servers built with GolfMCP.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def generate_otel_lifespan_code(default_exporter: str = "console", project_name: str = "UnknownGolfService") -> str:
|
|
10
|
-
"""Generate code for the OpenTelemetry lifespan function.
|
|
7
|
+
def generate_telemetry_imports() -> list[str]:
|
|
8
|
+
"""Generate import statements for telemetry instrumentation.
|
|
11
9
|
|
|
12
|
-
Args:
|
|
13
|
-
default_exporter: Default exporter type to use if OTEL_TRACES_EXPORTER is not set
|
|
14
|
-
project_name: The name of the project, used as default for OTEL_SERVICE_NAME
|
|
15
|
-
|
|
16
10
|
Returns:
|
|
17
|
-
|
|
11
|
+
List of import statements for telemetry
|
|
18
12
|
"""
|
|
19
|
-
return
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
import logging
|
|
29
|
-
from opentelemetry import trace
|
|
30
|
-
from opentelemetry.trace import NoOpTracerProvider
|
|
31
|
-
from opentelemetry.sdk.trace import TracerProvider
|
|
32
|
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
33
|
-
from opentelemetry.sdk.resources import Resource as OtelResource
|
|
34
|
-
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
35
|
-
|
|
36
|
-
# Optional: Configure OpenTelemetry's own logger to be less verbose
|
|
37
|
-
# logging.getLogger('opentelemetry').setLevel(logging.WARNING)
|
|
38
|
-
|
|
39
|
-
@asynccontextmanager
|
|
40
|
-
async def otel_lifespan(app): # 'app' is the FastMCP instance passed by _lifespan_wrapper
|
|
41
|
-
global _golf_otel_provider_global, _golf_otel_initialized_flag
|
|
13
|
+
return [
|
|
14
|
+
"# OpenTelemetry instrumentation imports",
|
|
15
|
+
"from golf.telemetry import (",
|
|
16
|
+
" instrument_tool,",
|
|
17
|
+
" instrument_resource,",
|
|
18
|
+
" instrument_prompt,",
|
|
19
|
+
" telemetry_lifespan,",
|
|
20
|
+
")",
|
|
21
|
+
]
|
|
42
22
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
23
|
+
def generate_component_registration_with_telemetry(
|
|
24
|
+
component_type: str,
|
|
25
|
+
component_name: str,
|
|
26
|
+
module_path: str,
|
|
27
|
+
entry_function: str,
|
|
28
|
+
docstring: str = "",
|
|
29
|
+
uri_template: str = None
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Generate component registration code with telemetry instrumentation.
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
current_global_otel_provider = trace.get_tracer_provider()
|
|
56
|
-
if isinstance(current_global_otel_provider, NoOpTracerProvider):
|
|
57
|
-
print(f"[OTel] Initializing OpenTelemetry Globals (service={{service_name_to_use}}, exporter={{exporter_type_to_use}})...", file=sys.stderr)
|
|
58
|
-
|
|
59
|
-
resource = OtelResource.create({{"service.name": service_name_to_use}})
|
|
60
|
-
provider_to_set = TracerProvider(resource=resource)
|
|
61
|
-
|
|
62
|
-
exporter = None
|
|
63
|
-
if exporter_type_to_use == "otlp_http":
|
|
64
|
-
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
65
|
-
exporter = OTLPSpanExporter(endpoint=endpoint) if endpoint else OTLPSpanExporter()
|
|
66
|
-
else: # Default to console
|
|
67
|
-
exporter = ConsoleSpanExporter(out=sys.stderr) # Ensure console output goes to stderr
|
|
68
|
-
|
|
69
|
-
processor = BatchSpanProcessor(exporter)
|
|
70
|
-
provider_to_set.add_span_processor(processor)
|
|
71
|
-
|
|
72
|
-
trace.set_tracer_provider(provider_to_set)
|
|
73
|
-
_golf_otel_provider_global = provider_to_set # Store it globally
|
|
74
|
-
_golf_otel_initialized_flag = True
|
|
75
|
-
local_provider_instance_for_shutdown = provider_to_set # Mark for shutdown by this invocation
|
|
76
|
-
print(f"[OTel] Global OpenTelemetry provider SET (service={{service_name_to_use}}, exporter={{exporter_type_to_use}})", file=sys.stderr)
|
|
77
|
-
else:
|
|
78
|
-
# A real provider is already set globally. Do not override.
|
|
79
|
-
# Assign it to _golf_otel_provider_global if not already assigned, for consistency,
|
|
80
|
-
# but don't mark it for shutdown by this specific lifespan invocation.
|
|
81
|
-
if _golf_otel_provider_global is None:
|
|
82
|
-
_golf_otel_provider_global = current_global_otel_provider
|
|
83
|
-
_golf_otel_initialized_flag = True # Mark as initialized to prevent re-entry by this mechanism
|
|
84
|
-
print(f"[OTel] Global OpenTelemetry provider was ALREADY SET by another mechanism. Using existing.", file=sys.stderr)
|
|
85
|
-
else:
|
|
86
|
-
# print(f"[OTel Lifespan DEBUG] Already initialized by this mechanism. Yielding.", file=sys.stderr)
|
|
87
|
-
pass # Already initialized by this mechanism in a previous entry
|
|
88
|
-
|
|
89
|
-
yield {{}} # Application runs here
|
|
33
|
+
Args:
|
|
34
|
+
component_type: Type of component ('tool', 'resource', 'prompt')
|
|
35
|
+
component_name: Name of the component
|
|
36
|
+
module_path: Full module path to the component
|
|
37
|
+
entry_function: Entry function name
|
|
38
|
+
docstring: Component description
|
|
39
|
+
uri_template: URI template for resources (optional)
|
|
90
40
|
|
|
91
|
-
except Exception as e:
|
|
92
|
-
print("[OTel] ERROR during OpenTelemetry setup/yield: " + str(e), file=sys.stderr)
|
|
93
|
-
import traceback
|
|
94
|
-
print(traceback.format_exc(), file=sys.stderr)
|
|
95
|
-
raise
|
|
96
|
-
finally:
|
|
97
|
-
# Only the instance of otel_lifespan that successfully initialized the provider
|
|
98
|
-
# should be responsible for its shutdown.
|
|
99
|
-
if local_provider_instance_for_shutdown:
|
|
100
|
-
# print(f"[OTel Lifespan DEBUG] Shutting down OTel Provider for service={{service_name_to_use}}.", file=sys.stderr)
|
|
101
|
-
local_provider_instance_for_shutdown.shutdown()
|
|
102
|
-
_golf_otel_initialized_flag = False # Allow re-init if server process truly restarts
|
|
103
|
-
_golf_otel_provider_global = None
|
|
104
|
-
print("[OTel] Provider shut down by this lifespan instance.", file=sys.stderr)
|
|
105
|
-
# --- OpenTelemetry Lifespan End ---
|
|
106
|
-
"""
|
|
107
|
-
|
|
108
|
-
def generate_otel_instrumentation_code() -> str:
|
|
109
|
-
"""Generate code for instrumenting the FastMCP instance.
|
|
110
|
-
|
|
111
41
|
Returns:
|
|
112
|
-
Python code string for
|
|
42
|
+
Python code string for registering the component with instrumentation
|
|
113
43
|
"""
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
121
|
-
|
|
122
|
-
print("[OTel Instrumentation] Applying FastMCP method wrappers...", file=sys.stderr)
|
|
123
|
-
|
|
124
|
-
# Create a tracer for the instrumentation
|
|
125
|
-
otel_tracer = otel_trace.get_tracer("golfmcp.fastmcp", "{__version__}")
|
|
126
|
-
print(f"[OTel Instrumentation] Acquired tracer: {{str(otel_tracer)}}", file=sys.stderr)
|
|
127
|
-
|
|
128
|
-
def otel_operation_wrapper(operation_name_suffix):
|
|
129
|
-
def wrapper(wrapped, instance, args, kwargs):
|
|
130
|
-
component_name = args[0] if args else "unknown"
|
|
131
|
-
span_name = "mcp." + operation_name_suffix
|
|
132
|
-
# print(f"[OTel Instrumentation DEBUG] Wrapping: {{instance.name}}.{{operation_name_suffix}} for component: {{component_name}}", file=sys.stderr)
|
|
44
|
+
func_ref = f"{module_path}.{entry_function}"
|
|
45
|
+
escaped_docstring = docstring.replace('"', '\\"') if docstring else ""
|
|
46
|
+
|
|
47
|
+
if component_type == "tool":
|
|
48
|
+
wrapped_func = f"instrument_tool({func_ref}, '{component_name}')"
|
|
49
|
+
return f'mcp.add_tool({wrapped_func}, name="{component_name}", description="{escaped_docstring}")'
|
|
133
50
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if component_name != "unknown":
|
|
142
|
-
component_type = operation_name_suffix.split("_")[1] if "_" in operation_name_suffix else "component"
|
|
143
|
-
span.set_attribute("mcp." + component_type + ".name", str(component_name))
|
|
144
|
-
|
|
145
|
-
# For call_tool, add parameters (carefully, to avoid sensitive data)
|
|
146
|
-
if operation_name_suffix == "call_tool" and len(args) > 1 and args[1]:
|
|
147
|
-
try:
|
|
148
|
-
params_str = json.dumps(args[1])
|
|
149
|
-
# Truncate long parameter strings to avoid huge spans
|
|
150
|
-
span.set_attribute("mcp.request.arguments", params_str[:1024] if len(params_str) > 1024 else params_str)
|
|
151
|
-
except Exception:
|
|
152
|
-
span.set_attribute("mcp.request.arguments", "[serialization_error]")
|
|
153
|
-
|
|
154
|
-
try:
|
|
155
|
-
# Call the original method
|
|
156
|
-
result = wrapped(*args, **kwargs)
|
|
157
|
-
|
|
158
|
-
# Set success status
|
|
159
|
-
span.set_status(Status(StatusCode.OK))
|
|
160
|
-
|
|
161
|
-
# Add result count for list operations
|
|
162
|
-
if "list" in operation_name_suffix and isinstance(result, list):
|
|
163
|
-
span.set_attribute("mcp.response.count", len(result))
|
|
164
|
-
|
|
165
|
-
return result
|
|
166
|
-
except Exception as e:
|
|
167
|
-
# Record the exception and set error status
|
|
168
|
-
span.record_exception(e)
|
|
169
|
-
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
170
|
-
raise
|
|
51
|
+
elif component_type == "resource":
|
|
52
|
+
wrapped_func = f"instrument_resource({func_ref}, '{uri_template}')"
|
|
53
|
+
return f'mcp.add_resource_fn({wrapped_func}, uri="{uri_template}", name="{component_name}", description="{escaped_docstring}")'
|
|
54
|
+
|
|
55
|
+
elif component_type == "prompt":
|
|
56
|
+
wrapped_func = f"instrument_prompt({func_ref}, '{component_name}')"
|
|
57
|
+
return f'mcp.add_prompt({wrapped_func}, name="{component_name}", description="{escaped_docstring}")'
|
|
171
58
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# Define the methods to instrument
|
|
175
|
-
methods_to_patch = [
|
|
176
|
-
("_mcp_call_tool", "call_tool"),
|
|
177
|
-
("_mcp_read_resource", "read_resource"),
|
|
178
|
-
("_mcp_get_prompt", "get_prompt"),
|
|
179
|
-
("_mcp_list_tools", "list_tools"),
|
|
180
|
-
("_mcp_list_resources", "list_resources"),
|
|
181
|
-
("_mcp_list_resource_templates", "list_resource_templates"),
|
|
182
|
-
("_mcp_list_prompts", "list_prompts")
|
|
183
|
-
]
|
|
184
|
-
|
|
185
|
-
# Apply instrumentation to each method
|
|
186
|
-
# This code runs in server.py *after* 'mcp' is defined.
|
|
187
|
-
# The 'mcp' variable needs to be in the scope where this instrumentation code is placed.
|
|
188
|
-
for method_name, operation_suffix in methods_to_patch:
|
|
189
|
-
if hasattr(mcp, method_name): # 'mcp' should be the FastMCP instance
|
|
190
|
-
# print(f"[OTel Instrumentation DEBUG] Patching {{method_name}} on {{mcp}}", file=sys.stderr)
|
|
191
|
-
wrapt.wrap_function_wrapper(mcp, method_name, otel_operation_wrapper(operation_name_suffix))
|
|
192
|
-
|
|
193
|
-
print("[OTel Instrumentation] MCP method instrumentation attempted.", file=sys.stderr)
|
|
194
|
-
"""
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"Unknown component type: {component_type}")
|
|
195
61
|
|
|
196
62
|
def get_otel_dependencies() -> list[str]:
|
|
197
63
|
"""Get list of OpenTelemetry dependencies to add to pyproject.toml.
|
|
@@ -204,5 +70,4 @@ def get_otel_dependencies() -> list[str]:
|
|
|
204
70
|
"opentelemetry-sdk>=1.18.0",
|
|
205
71
|
"opentelemetry-instrumentation-asgi>=0.40b0",
|
|
206
72
|
"opentelemetry-exporter-otlp-proto-http>=0.40b0",
|
|
207
|
-
"wrapt>=1.14.0",
|
|
208
73
|
]
|
golf/core/telemetry.py
CHANGED
|
@@ -6,6 +6,7 @@ import platform
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Optional, Dict, Any
|
|
8
8
|
import json
|
|
9
|
+
import uuid
|
|
9
10
|
|
|
10
11
|
import posthog
|
|
11
12
|
from rich.console import Console
|
|
@@ -24,6 +25,7 @@ POSTHOG_HOST = "https://us.i.posthog.com"
|
|
|
24
25
|
# Telemetry state
|
|
25
26
|
_telemetry_enabled: Optional[bool] = None
|
|
26
27
|
_anonymous_id: Optional[str] = None
|
|
28
|
+
_user_identified: bool = False # Track if we've already identified the user
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
def get_telemetry_config_path() -> Path:
|
|
@@ -115,8 +117,7 @@ def set_telemetry_enabled(enabled: bool, persist: bool = True) -> None:
|
|
|
115
117
|
def get_anonymous_id() -> str:
|
|
116
118
|
"""Get or create a persistent anonymous ID for this machine.
|
|
117
119
|
|
|
118
|
-
The ID is stored in the user's home directory and is
|
|
119
|
-
machine characteristics to be consistent across sessions.
|
|
120
|
+
The ID is stored in the user's home directory and is unique per installation.
|
|
120
121
|
"""
|
|
121
122
|
global _anonymous_id
|
|
122
123
|
|
|
@@ -129,16 +130,29 @@ def get_anonymous_id() -> str:
|
|
|
129
130
|
if id_file.exists():
|
|
130
131
|
try:
|
|
131
132
|
_anonymous_id = id_file.read_text().strip()
|
|
132
|
-
if
|
|
133
|
+
# Check if ID is in the old format (no hyphen between hash and random component)
|
|
134
|
+
# Old format: golf-[8 chars hash][8 chars random]
|
|
135
|
+
# New format: golf-[8 chars hash]-[8 chars random]
|
|
136
|
+
if _anonymous_id and _anonymous_id.startswith("golf-") and len(_anonymous_id) == 21:
|
|
137
|
+
# This is likely the old format, regenerate
|
|
138
|
+
_anonymous_id = None
|
|
139
|
+
elif _anonymous_id:
|
|
133
140
|
return _anonymous_id
|
|
134
141
|
except Exception:
|
|
135
142
|
pass
|
|
136
143
|
|
|
137
|
-
# Generate new ID
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
# Generate new ID with more unique data
|
|
145
|
+
# Use only non-identifying system information
|
|
146
|
+
|
|
147
|
+
# Combine non-identifying factors for uniqueness
|
|
148
|
+
machine_data = f"{platform.machine()}-{platform.system()}-{platform.python_version()}"
|
|
149
|
+
machine_hash = hashlib.sha256(machine_data.encode()).hexdigest()[:8]
|
|
150
|
+
|
|
151
|
+
# Add a random component to ensure uniqueness
|
|
152
|
+
random_component = str(uuid.uuid4()).split('-')[0] # First 8 chars of UUID
|
|
153
|
+
|
|
154
|
+
# Use hyphen separator for clarity and ensure PostHog treats these as different IDs
|
|
155
|
+
_anonymous_id = f"golf-{machine_hash}-{random_component}"
|
|
142
156
|
|
|
143
157
|
# Try to save for next time
|
|
144
158
|
try:
|
|
@@ -180,6 +194,8 @@ def track_event(event_name: str, properties: Optional[Dict[str, Any]] = None) ->
|
|
|
180
194
|
event_name: Name of the event (e.g., "cli_init", "cli_build")
|
|
181
195
|
properties: Optional properties to include with the event
|
|
182
196
|
"""
|
|
197
|
+
global _user_identified
|
|
198
|
+
|
|
183
199
|
if not is_telemetry_enabled():
|
|
184
200
|
return
|
|
185
201
|
|
|
@@ -195,11 +211,33 @@ def track_event(event_name: str, properties: Optional[Dict[str, Any]] = None) ->
|
|
|
195
211
|
# Get anonymous ID
|
|
196
212
|
anonymous_id = get_anonymous_id()
|
|
197
213
|
|
|
214
|
+
# Only identify the user once per session
|
|
215
|
+
if not _user_identified:
|
|
216
|
+
# Set person properties to differentiate installations
|
|
217
|
+
# Only include non-identifying information
|
|
218
|
+
person_properties = {
|
|
219
|
+
"$set": {
|
|
220
|
+
"golf_version": __version__,
|
|
221
|
+
"os": platform.system(),
|
|
222
|
+
"python_version": f"{platform.python_version_tuple()[0]}.{platform.python_version_tuple()[1]}",
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Identify the user with properties
|
|
227
|
+
posthog.identify(
|
|
228
|
+
distinct_id=anonymous_id,
|
|
229
|
+
properties=person_properties
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
_user_identified = True
|
|
233
|
+
|
|
198
234
|
# Only include minimal, non-identifying properties
|
|
199
235
|
safe_properties = {
|
|
200
236
|
"golf_version": __version__,
|
|
201
237
|
"python_version": f"{platform.python_version_tuple()[0]}.{platform.python_version_tuple()[1]}",
|
|
202
238
|
"os": platform.system(),
|
|
239
|
+
# Explicitly disable IP tracking
|
|
240
|
+
"$ip": None,
|
|
203
241
|
}
|
|
204
242
|
|
|
205
243
|
# Filter properties to only include safe ones
|
golf/examples/api_key/golf.json
CHANGED
golf/examples/basic/.env.example
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
GOLF_CLIENT_ID="default-client-id"
|
|
2
2
|
GOLF_CLIENT_SECRET="default-secret"
|
|
3
|
-
JWT_SECRET="example-jwt-secret-for-development-only"
|
|
3
|
+
JWT_SECRET="example-jwt-secret-for-development-only"
|
|
4
|
+
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces"
|
|
5
|
+
OTEL_SERVICE_NAME="golf-mcp"
|