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

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

Potentially problematic release.


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

golf/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) -> str:
12
- """Generate code for setting up authentication in the FastMCP app.
13
- This code string will be injected into the generated server.py and executed at its runtime.
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 generate_api_key_auth_code(server_name)
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 from pre_build.py, just generate basic FastMCP instantiation
25
- return f"mcp = FastMCP({repr(server_name)}) # No authentication configured"
26
-
27
- # This list will hold lines of Python code to be written into server.py
28
- generated_code_lines = []
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
- # Imports needed at the top of server.py for the auth setup block
31
- # Note: FastMCP itself is imported by the main server generation logic later.
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
- generated_code_lines.extend([
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
- generated_code_lines.extend([
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
- generated_code_lines.extend([
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 and FastMCP instantiation
105
- generated_code_lines.extend([
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,31 +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)
125
+
126
+ # FastMCP constructor arguments
127
+ fastmcp_args = {
128
+ "auth_server_provider": "auth_provider",
129
+ "auth": "auth_settings"
130
+ }
131
+
132
+ return {
133
+ "imports": auth_imports,
134
+ "setup_code": setup_code_lines,
135
+ "fastmcp_args": fastmcp_args,
136
+ "has_auth": True
137
+ }
122
138
 
123
139
 
124
- def generate_api_key_auth_code(server_name: str) -> str:
125
- """Generate code for API key authentication middleware."""
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 f"mcp = FastMCP({repr(server_name)}) # No API key authentication configured"
152
+ return {
153
+ "imports": [],
154
+ "setup_code": [],
155
+ "fastmcp_args": {},
156
+ "has_auth": False,
157
+ "post_init_code": []
158
+ }
129
159
 
130
- generated_code_lines = []
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
- "",
140
- f"mcp = FastMCP({repr(server_name)})",
141
- "",
166
+ "from starlette.responses import JSONResponse",
167
+ ]
168
+
169
+ setup_code_lines = [
142
170
  "# Middleware to extract API key from headers",
143
171
  "class ApiKeyMiddleware(BaseHTTPMiddleware):",
144
172
  " async def dispatch(self, request: Request, call_next):",
@@ -155,9 +183,24 @@ def generate_api_key_auth_code(server_name: str) -> str:
155
183
  " api_key = v",
156
184
  " break",
157
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
+ " ",
158
194
  " # Strip prefix if configured and present",
159
195
  " if api_key and header_prefix and api_key.startswith(header_prefix):",
160
196
  " api_key = api_key[len(header_prefix):]",
197
+ " elif api_key and header_prefix and api_key_config.required:",
198
+ " # Has API key but wrong format when required",
199
+ " return JSONResponse(",
200
+ " {'error': 'unauthorized', 'detail': f'Invalid {header_name} format, expected prefix: {header_prefix}'},",
201
+ " status_code=401,",
202
+ " headers={'WWW-Authenticate': f'{header_name} realm=\"MCP Server\"'}",
203
+ " )",
161
204
  " ",
162
205
  " # Store the API key in context for tools to access",
163
206
  " set_api_key(api_key)",
@@ -166,11 +209,84 @@ def generate_api_key_auth_code(server_name: str) -> str:
166
209
  " response = await call_next(request)",
167
210
  " return response",
168
211
  "",
169
- "# Add the middleware to the FastMCP app",
170
- "mcp.app.add_middleware(ApiKeyMiddleware)",
171
- ])
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
+ ]
172
282
 
173
- return "\n".join(generated_code_lines)
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
+ }
174
290
 
175
291
 
176
292
  def generate_auth_routes() -> str:
@@ -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
- from golf import __version__
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
- Python code string for the OpenTelemetry lifespan function
11
+ List of import statements for telemetry
18
12
  """
19
- return f"""
20
- # --- OpenTelemetry Lifespan Start ---
21
- # These variables are global within the generated server.py module scope
22
- _golf_otel_provider_global = None
23
- _golf_otel_initialized_flag = False
24
-
25
- from contextlib import asynccontextmanager
26
- import os
27
- import sys
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
- # These will be resolved when otel_lifespan runs in the generated server.py
44
- # .format() inserts build-time fallbacks {project_name} and {default_exporter}
45
- service_name_to_use = os.environ.get("OTEL_SERVICE_NAME", "{project_name}")
46
- exporter_type_to_use = os.environ.get("OTEL_TRACES_EXPORTER", "{default_exporter}").lower()
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
- # Local variable for the provider created in this specific call, if any.
49
- local_provider_instance_for_shutdown = None
50
-
51
- try:
52
- if not _golf_otel_initialized_flag:
53
- # Double check if a real provider is already globally set by another mechanism
54
- # trace.get_tracer_provider() returns a NoOpTracerProvider by default.
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 instrumenting FastMCP methods
42
+ Python code string for registering the component with instrumentation
113
43
  """
114
- return f"""
115
- # Instrument FastMCP instance
116
- import wrapt
117
- import json
118
- import sys # For debug prints
119
- from opentelemetry import trace as otel_trace
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
- with otel_tracer.start_as_current_span(span_name, kind=SpanKind.SERVER) as span:
135
- # print(f"[OTel Instrumentation DEBUG] Started span: {{span_name}}, Trace ID: {{span.get_span_context().trace_id}}", file=sys.stderr)
136
- span.set_attribute("rpc.system", "mcp")
137
- span.set_attribute("rpc.method", operation_name_suffix)
138
- span.set_attribute("rpc.service", instance.name)
139
-
140
- # Set operation-specific attributes
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
- return wrapper
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,8 @@ import platform
6
6
  from pathlib import Path
7
7
  from typing import Optional, Dict, Any
8
8
  import json
9
+ import uuid
10
+ import getpass
9
11
 
10
12
  import posthog
11
13
  from rich.console import Console
@@ -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 based on
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,35 @@ 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 _anonymous_id:
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 based on machine characteristics
138
- # This ensures the same ID across sessions on the same machine
139
- machine_data = f"{platform.node()}-{platform.machine()}-{platform.system()}"
140
- machine_hash = hashlib.sha256(machine_data.encode()).hexdigest()[:16]
141
- _anonymous_id = f"golf-{machine_hash}"
144
+ # Generate new ID with more unique data
145
+ # Include home directory path to differentiate between users on same machine
146
+ # Include a random component to ensure uniqueness even with identical setups
147
+
148
+ try:
149
+ username = getpass.getuser()
150
+ except Exception:
151
+ username = "unknown"
152
+
153
+ # Combine multiple factors for uniqueness
154
+ machine_data = f"{platform.node()}-{platform.machine()}-{platform.system()}-{username}-{str(Path.home())}"
155
+ machine_hash = hashlib.sha256(machine_data.encode()).hexdigest()[:8]
156
+
157
+ # Add a random component to ensure uniqueness
158
+ random_component = str(uuid.uuid4()).split('-')[0] # First 8 chars of UUID
159
+
160
+ # Use hyphen separator for clarity and ensure PostHog treats these as different IDs
161
+ _anonymous_id = f"golf-{machine_hash}-{random_component}"
142
162
 
143
163
  # Try to save for next time
144
164
  try:
@@ -195,6 +215,28 @@ def track_event(event_name: str, properties: Optional[Dict[str, Any]] = None) ->
195
215
  # Get anonymous ID
196
216
  anonymous_id = get_anonymous_id()
197
217
 
218
+ # Set person properties to differentiate installations
219
+ # This helps PostHog understand these are different users
220
+ try:
221
+ hostname = platform.node()
222
+ except Exception:
223
+ hostname = "unknown"
224
+
225
+ person_properties = {
226
+ "$set": {
227
+ "golf_version": __version__,
228
+ "os": platform.system(),
229
+ "hostname": hostname,
230
+ "python_version": f"{platform.python_version_tuple()[0]}.{platform.python_version_tuple()[1]}",
231
+ }
232
+ }
233
+
234
+ # Identify the user with properties
235
+ posthog.identify(
236
+ distinct_id=anonymous_id,
237
+ properties=person_properties
238
+ )
239
+
198
240
  # Only include minimal, non-identifying properties
199
241
  safe_properties = {
200
242
  "golf_version": __version__,
@@ -0,0 +1,2 @@
1
+ OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces"
2
+ OTEL_SERVICE_NAME="golf-mcp"
@@ -0,0 +1,5 @@
1
+ GOLF_CLIENT_ID="default-client-id"
2
+ GOLF_CLIENT_SECRET="default-secret"
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"