golf-mcp 0.1.0__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.

Files changed (41) hide show
  1. golf/__init__.py +1 -0
  2. golf/auth/__init__.py +109 -0
  3. golf/auth/helpers.py +56 -0
  4. golf/auth/oauth.py +798 -0
  5. golf/auth/provider.py +110 -0
  6. golf/cli/__init__.py +1 -0
  7. golf/cli/main.py +223 -0
  8. golf/commands/__init__.py +3 -0
  9. golf/commands/build.py +78 -0
  10. golf/commands/init.py +197 -0
  11. golf/commands/run.py +68 -0
  12. golf/core/__init__.py +1 -0
  13. golf/core/builder.py +1169 -0
  14. golf/core/builder_auth.py +157 -0
  15. golf/core/builder_telemetry.py +208 -0
  16. golf/core/config.py +205 -0
  17. golf/core/parser.py +509 -0
  18. golf/core/transformer.py +168 -0
  19. golf/examples/__init__.py +1 -0
  20. golf/examples/basic/.env +3 -0
  21. golf/examples/basic/.env.example +3 -0
  22. golf/examples/basic/README.md +117 -0
  23. golf/examples/basic/golf.json +9 -0
  24. golf/examples/basic/pre_build.py +28 -0
  25. golf/examples/basic/prompts/welcome.py +30 -0
  26. golf/examples/basic/resources/current_time.py +41 -0
  27. golf/examples/basic/resources/info.py +27 -0
  28. golf/examples/basic/resources/weather/common.py +48 -0
  29. golf/examples/basic/resources/weather/current.py +32 -0
  30. golf/examples/basic/resources/weather/forecast.py +32 -0
  31. golf/examples/basic/tools/github_user.py +67 -0
  32. golf/examples/basic/tools/hello.py +29 -0
  33. golf/examples/basic/tools/payments/charge.py +50 -0
  34. golf/examples/basic/tools/payments/common.py +34 -0
  35. golf/examples/basic/tools/payments/refund.py +50 -0
  36. golf_mcp-0.1.0.dist-info/METADATA +78 -0
  37. golf_mcp-0.1.0.dist-info/RECORD +41 -0
  38. golf_mcp-0.1.0.dist-info/WHEEL +5 -0
  39. golf_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  40. golf_mcp-0.1.0.dist-info/licenses/LICENSE +201 -0
  41. golf_mcp-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,157 @@
1
+ """Authentication integration for the GolfMCP build process.
2
+
3
+ This module adds support for injecting authentication configuration
4
+ into the generated FastMCP application during the build process.
5
+ """
6
+
7
+ from golf.auth import get_auth_config
8
+
9
+
10
+ def generate_auth_code(server_name: str, host: str = "127.0.0.1", port: int = 3000, https: bool = False) -> str:
11
+ """Generate code for setting up authentication in the FastMCP app.
12
+ This code string will be injected into the generated server.py and executed at its runtime.
13
+ """
14
+ original_provider_config, required_scopes_from_config = get_auth_config()
15
+
16
+ if not original_provider_config:
17
+ # If no auth config from pre_build.py, just generate basic FastMCP instantiation
18
+ return f"mcp = FastMCP({repr(server_name)}) # No authentication configured"
19
+
20
+ # This list will hold lines of Python code to be written into server.py
21
+ generated_code_lines = []
22
+
23
+ # Imports needed at the top of server.py for the auth setup block
24
+ # Note: FastMCP itself is imported by the main server generation logic later.
25
+ generated_code_lines.extend([
26
+ "import os",
27
+ "import sys # For stderr output",
28
+ "from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions",
29
+ "from golf.auth.provider import ProviderConfig as GolfProviderConfigInternal # Alias to avoid conflict if user also has ProviderConfig",
30
+ "from golf.auth.oauth import GolfOAuthProvider",
31
+ "# get_access_token and create_callback_handler are used by generated auth_routes",
32
+ "from golf.auth import get_access_token, create_callback_handler",
33
+ "",
34
+ ])
35
+
36
+ # Code to determine runtime server address configuration
37
+ generated_code_lines.extend([
38
+ "# Determine runtime server address configuration",
39
+ f"runtime_host = os.environ.get('HOST', {repr(host)})",
40
+ f"runtime_port = int(os.environ.get('PORT', {repr(port)}))",
41
+ f"runtime_protocol = 'https' if os.environ.get('HTTPS', {repr(str(https)).lower()}).lower() in ('1', 'true', 'yes') else 'http'",
42
+ "",
43
+ "# Determine proper issuer URL at runtime for this server instance",
44
+ "include_port = (runtime_protocol == 'http' and runtime_port != 80) or (runtime_protocol == 'https' and runtime_port != 443)",
45
+ "if include_port:",
46
+ " runtime_issuer_url = f'{runtime_protocol}://{runtime_host}:{runtime_port}'",
47
+ "else:",
48
+ " runtime_issuer_url = f'{runtime_protocol}://{runtime_host}'",
49
+ "",
50
+ ])
51
+
52
+ # Code to load secrets from environment variables AT RUNTIME in server.py
53
+ generated_code_lines.extend([
54
+ "# Load secrets from environment variables using names specified in pre_build.py ProviderConfig",
55
+ f"runtime_client_id = os.environ.get({repr(original_provider_config.client_id_env_var)})",
56
+ f"runtime_client_secret = os.environ.get({repr(original_provider_config.client_secret_env_var)})",
57
+ f"runtime_jwt_secret = os.environ.get({repr(original_provider_config.jwt_secret_env_var)})",
58
+ "",
59
+ "# Check and warn if essential secrets are missing",
60
+ "if not runtime_client_id:",
61
+ f" print(f\"AUTH WARNING: Environment variable '{original_provider_config.client_id_env_var}' for OAuth Client ID is not set. Authentication will likely fail.\", file=sys.stderr)",
62
+ "if not runtime_client_secret:",
63
+ f" print(f\"AUTH WARNING: Environment variable '{original_provider_config.client_secret_env_var}' for OAuth Client Secret is not set. Authentication will likely fail.\", file=sys.stderr)",
64
+ "if not runtime_jwt_secret:",
65
+ f" print(f\"AUTH WARNING: Environment variable '{original_provider_config.jwt_secret_env_var}' for JWT Secret is not set. Using a default insecure fallback. DO NOT USE IN PRODUCTION.\", file=sys.stderr)",
66
+ f" runtime_jwt_secret = {repr(f'fallback-dev-jwt-secret-for-{server_name}-!PLEASE-CHANGE-THIS!')}", # Fixed fallback string
67
+ "",
68
+ ])
69
+
70
+ # Code to instantiate ProviderConfig using runtime-loaded secrets and other baked-in non-secrets
71
+ generated_code_lines.extend([
72
+ "# Instantiate ProviderConfig with runtime-resolved secrets and other pre-configured values",
73
+ f"provider_config_instance = GolfProviderConfigInternal(", # Use aliased import
74
+ f" provider={repr(original_provider_config.provider)},",
75
+ f" client_id_env_var={repr(original_provider_config.client_id_env_var)},",
76
+ f" client_secret_env_var={repr(original_provider_config.client_secret_env_var)},",
77
+ f" jwt_secret_env_var={repr(original_provider_config.jwt_secret_env_var)},",
78
+ f" client_id=runtime_client_id,",
79
+ f" client_secret=runtime_client_secret,",
80
+ f" jwt_secret=runtime_jwt_secret,",
81
+ f" authorize_url={repr(original_provider_config.authorize_url)},",
82
+ f" token_url={repr(original_provider_config.token_url)},",
83
+ f" userinfo_url={repr(original_provider_config.userinfo_url)},",
84
+ f" jwks_uri={repr(original_provider_config.jwks_uri)},",
85
+ f" scopes={repr(original_provider_config.scopes)},",
86
+ f" issuer_url=runtime_issuer_url,",
87
+ f" callback_path={repr(original_provider_config.callback_path)},",
88
+ f" token_expiration={original_provider_config.token_expiration}",
89
+ ")",
90
+ "",
91
+ "auth_provider = GolfOAuthProvider(provider_config_instance)",
92
+ "from golf.auth.helpers import _set_active_golf_oauth_provider # Ensure helper is imported",
93
+ "_set_active_golf_oauth_provider(auth_provider) # Make provider instance available",
94
+ "",
95
+ ])
96
+
97
+ # AuthSettings and FastMCP instantiation
98
+ generated_code_lines.extend([
99
+ "# Create auth settings for FastMCP",
100
+ "auth_settings = AuthSettings(",
101
+ " issuer_url=runtime_issuer_url,",
102
+ " client_registration_options=ClientRegistrationOptions(",
103
+ " enabled=True,",
104
+ f" valid_scopes={repr(original_provider_config.scopes)},",
105
+ f" default_scopes={repr(original_provider_config.scopes)}",
106
+ " ),",
107
+ f" required_scopes={repr(required_scopes_from_config) if required_scopes_from_config else None}",
108
+ ")",
109
+ "",
110
+ "# Create FastMCP instance with auth configuration",
111
+ f"mcp = FastMCP({repr(server_name)}, auth_server_provider=auth_provider, auth=auth_settings)"
112
+ ])
113
+
114
+ return "\n".join(generated_code_lines)
115
+
116
+
117
+ def generate_auth_routes() -> str:
118
+ """Generate code for OAuth routes in the FastMCP app.
119
+ These routes are added to the FastMCP instance (`mcp`) created by `generate_auth_code`.
120
+ """
121
+ provider_config, _ = get_auth_config() # Used to check if auth is enabled generally
122
+ if not provider_config:
123
+ return ""
124
+
125
+ auth_routes_list = [
126
+ "",
127
+ "# Auth-specific routes, using the 'auth_provider' instance defined in the auth setup code",
128
+ "@mcp.custom_route('/auth/callback', methods=['GET'])",
129
+ "async def oauth_callback(request):",
130
+ " # create_callback_handler is imported in the auth setup code block",
131
+ " handler = create_callback_handler(auth_provider)",
132
+ " return await handler(request)",
133
+ "",
134
+ "@mcp.custom_route('/login', methods=['GET'])",
135
+ "async def login(request):",
136
+ " from starlette.responses import RedirectResponse",
137
+ " import urllib.parse",
138
+ " default_redirect_uri = urllib.parse.quote_plus(\"http://localhost:5173/callback\")",
139
+ " authorize_url = f\"/mcp/auth/authorize?client_id=default&response_type=code&redirect_uri={default_redirect_uri}\"",
140
+ " return RedirectResponse(authorize_url)",
141
+ "",
142
+ "@mcp.custom_route('/auth-error', methods=['GET'])",
143
+ "async def auth_error(request):",
144
+ " from starlette.responses import HTMLResponse",
145
+ " error = request.query_params.get('error', 'unknown_error')",
146
+ " error_desc = request.query_params.get('error_description', 'An authentication error occurred')",
147
+ " html_content = f\"\"\"",
148
+ " <!DOCTYPE html>",
149
+ " <html><head><title>Authentication Error</title>",
150
+ " <style> body {{ font-family: system-ui, sans-serif; padding: 2rem; max-width: 600px; margin: auto; }} h1 {{ color: #c53030; }} .error-box {{ background-color: #fed7d7; border: 1px solid #f56565; border-radius: 0.25rem; padding: 1rem; margin: 1rem 0; }} .btn {{ background-color: #4299e1; color: white; border: none; padding: 0.5rem 1rem; border-radius: 0.25rem; cursor: pointer; text-decoration: none; display: inline-block; margin-top: 1rem; }} .btn:hover {{ background-color: #2b6cb0; }} </style>",
151
+ " </head><body><h1>Authentication Failed</h1><div class='error-box'>",
152
+ " <p><strong>Error:</strong> {error}</p><p><strong>Description:</strong> {error_desc}</p></div>",
153
+ " <a href='/login' class='btn'>Try Again</a></body></html>\"\"\"",
154
+ " return HTMLResponse(content=html_content)",
155
+ "",
156
+ ]
157
+ return "\n".join(auth_routes_list)
@@ -0,0 +1,208 @@
1
+ """OpenTelemetry integration for the GolfMCP build process.
2
+
3
+ This module provides functions for generating OpenTelemetry initialization
4
+ and instrumentation code for FastMCP servers built with GolfMCP.
5
+ """
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.
11
+
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
+ Returns:
17
+ Python code string for the OpenTelemetry lifespan function
18
+ """
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
42
+
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()
47
+
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
90
+
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
+ Returns:
112
+ Python code string for instrumenting FastMCP methods
113
+ """
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)
133
+
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
171
+
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
+ """
195
+
196
+ def get_otel_dependencies() -> list[str]:
197
+ """Get list of OpenTelemetry dependencies to add to pyproject.toml.
198
+
199
+ Returns:
200
+ List of package requirements strings
201
+ """
202
+ return [
203
+ "opentelemetry-api>=1.18.0",
204
+ "opentelemetry-sdk>=1.18.0",
205
+ "opentelemetry-instrumentation-asgi>=0.40b0",
206
+ "opentelemetry-exporter-otlp-proto-http>=0.40b0",
207
+ "wrapt>=1.14.0",
208
+ ]
golf/core/config.py ADDED
@@ -0,0 +1,205 @@
1
+ """Configuration management for GolfMCP."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional, Union, Tuple
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ class AuthConfig(BaseModel):
14
+ """Authentication configuration."""
15
+
16
+ provider: str = Field(
17
+ ..., description="Authentication provider (e.g., 'jwks', 'google', 'github')"
18
+ )
19
+ scopes: List[str] = Field(default_factory=list, description="Required OAuth scopes")
20
+ client_id_env: Optional[str] = Field(
21
+ None, description="Environment variable name for client ID"
22
+ )
23
+ client_secret_env: Optional[str] = Field(
24
+ None, description="Environment variable name for client secret"
25
+ )
26
+ redirect_uri: Optional[str] = Field(
27
+ None, description="OAuth redirect URI (defaults to localhost callback)"
28
+ )
29
+
30
+ @field_validator("provider")
31
+ @classmethod
32
+ def validate_provider(cls, value: str) -> str:
33
+ """Validate the provider value."""
34
+ valid_providers = {"jwks", "google", "github", "custom"}
35
+ if value not in valid_providers and not value.startswith("custom:"):
36
+ raise ValueError(
37
+ f"Invalid provider '{value}'. Must be one of {valid_providers} "
38
+ "or start with 'custom:'"
39
+ )
40
+ return value
41
+
42
+
43
+ class DeployConfig(BaseModel):
44
+ """Deployment configuration."""
45
+
46
+ default: str = Field("vercel", description="Default deployment target")
47
+ options: Dict[str, Any] = Field(
48
+ default_factory=dict, description="Target-specific options"
49
+ )
50
+
51
+
52
+ class Settings(BaseSettings):
53
+ """GolfMCP application settings."""
54
+
55
+ model_config = SettingsConfigDict(
56
+ env_prefix="GOLF_",
57
+ env_file=".env",
58
+ env_file_encoding="utf-8",
59
+ extra="ignore",
60
+ )
61
+
62
+ # Project metadata
63
+ name: str = Field("GolfMCP Project", description="FastMCP instance name")
64
+ description: Optional[str] = Field(None, description="Project description")
65
+
66
+ # Build settings
67
+ output_dir: str = Field("build", description="Build artifact folder")
68
+
69
+ # Server settings
70
+ host: str = Field("127.0.0.1", description="Server host")
71
+ port: int = Field(3000, description="Server port")
72
+ transport: str = Field("streamable-http", description="Transport protocol (streamable-http, sse, stdio)")
73
+
74
+ # Auth settings
75
+ auth: Optional[Union[str, AuthConfig]] = Field(
76
+ None, description="Authentication configuration or URI"
77
+ )
78
+
79
+ # Deploy settings
80
+ deploy: DeployConfig = Field(
81
+ default_factory=DeployConfig, description="Deployment configuration"
82
+ )
83
+
84
+ # Feature flags
85
+ telemetry: bool = Field(True, description="Enable anonymous telemetry")
86
+ hot_reload: bool = Field(True, description="Enable hot reload in dev mode")
87
+
88
+ # Project paths
89
+ tools_dir: str = Field("tools", description="Directory containing tools")
90
+ resources_dir: str = Field("resources", description="Directory containing resources")
91
+ prompts_dir: str = Field("prompts", description="Directory containing prompts")
92
+
93
+ # OpenTelemetry config
94
+ opentelemetry_enabled: bool = Field(False, description="Enable OpenTelemetry tracing")
95
+ opentelemetry_default_exporter: str = Field("console", description="Default OpenTelemetry exporter type")
96
+
97
+
98
+ def find_config_path(start_path: Optional[Path] = None) -> Optional[Path]:
99
+ """Find the golf config file by searching upwards from the given path.
100
+
101
+ Args:
102
+ start_path: Path to start searching from (defaults to current directory)
103
+
104
+ Returns:
105
+ Path to the config file if found, None otherwise
106
+ """
107
+ if start_path is None:
108
+ start_path = Path.cwd()
109
+
110
+ current = start_path.absolute()
111
+
112
+ # Don't search above the home directory
113
+ home = Path.home().absolute()
114
+
115
+ while current != current.parent and current != home:
116
+ # Check for JSON config first (preferred)
117
+ json_config = current / "golf.json"
118
+ if json_config.exists():
119
+ return json_config
120
+
121
+ # Fall back to TOML config
122
+ toml_config = current / "golf.toml"
123
+ if toml_config.exists():
124
+ return toml_config
125
+
126
+ current = current.parent
127
+
128
+ return None
129
+
130
+
131
+ def find_project_root(start_path: Optional[Path] = None) -> Tuple[Optional[Path], Optional[Path]]:
132
+ """Find a GolfMCP project root by searching for a config file.
133
+
134
+ This is the central project discovery function that should be used by all commands.
135
+
136
+ Args:
137
+ start_path: Path to start searching from (defaults to current directory)
138
+
139
+ Returns:
140
+ Tuple of (project_root, config_path) if a project is found, or (None, None) if not
141
+ """
142
+ config_path = find_config_path(start_path)
143
+ if config_path:
144
+ return config_path.parent, config_path
145
+ return None, None
146
+
147
+
148
+ def load_settings(project_path: Union[str, Path]) -> Settings:
149
+ """Load settings from a project directory.
150
+
151
+ Args:
152
+ project_path: Path to the project directory
153
+
154
+ Returns:
155
+ Settings object with values loaded from config files
156
+ """
157
+ # Convert to Path if needed
158
+ if isinstance(project_path, str):
159
+ project_path = Path(project_path)
160
+
161
+ # Create default settings
162
+ settings = Settings()
163
+
164
+ # Check for .env file
165
+ env_file = project_path / ".env"
166
+ if env_file.exists():
167
+ settings = Settings(_env_file=env_file)
168
+
169
+ # Try to load JSON config file first
170
+ json_config_path = project_path / "golf.json"
171
+ if json_config_path.exists():
172
+ return _load_json_settings(json_config_path, settings)
173
+
174
+ # Fall back to TOML config file if JSON not found
175
+ toml_config_path = project_path / "golf.toml"
176
+ if toml_config_path.exists():
177
+ console.print("[yellow]Warning: Using .toml configuration is deprecated. Please migrate to .json format.[/yellow]")
178
+ return _load_toml_settings(toml_config_path, settings)
179
+
180
+ # No config file found, use defaults
181
+ return settings
182
+
183
+
184
+ def _load_json_settings(path: Path, settings: Settings) -> Settings:
185
+ """Load settings from a JSON file."""
186
+ try:
187
+ import json
188
+ with open(path, "r") as f:
189
+ config_data = json.load(f)
190
+
191
+ # Update settings from config data
192
+ for key, value in config_data.items():
193
+ if hasattr(settings, key):
194
+ setattr(settings, key, value)
195
+
196
+ return settings
197
+ except Exception as e:
198
+ console.print(f"[bold red]Error loading JSON config from {path}: {e}[/bold red]")
199
+ return settings
200
+
201
+
202
+ def _load_toml_settings(path: Path, settings: Settings) -> Settings:
203
+ """Load settings from a TOML file."""
204
+
205
+ return settings