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.
- golf/__init__.py +1 -0
- golf/auth/__init__.py +109 -0
- golf/auth/helpers.py +56 -0
- golf/auth/oauth.py +798 -0
- golf/auth/provider.py +110 -0
- golf/cli/__init__.py +1 -0
- golf/cli/main.py +223 -0
- golf/commands/__init__.py +3 -0
- golf/commands/build.py +78 -0
- golf/commands/init.py +197 -0
- golf/commands/run.py +68 -0
- golf/core/__init__.py +1 -0
- golf/core/builder.py +1169 -0
- golf/core/builder_auth.py +157 -0
- golf/core/builder_telemetry.py +208 -0
- golf/core/config.py +205 -0
- golf/core/parser.py +509 -0
- golf/core/transformer.py +168 -0
- golf/examples/__init__.py +1 -0
- golf/examples/basic/.env +3 -0
- golf/examples/basic/.env.example +3 -0
- golf/examples/basic/README.md +117 -0
- golf/examples/basic/golf.json +9 -0
- golf/examples/basic/pre_build.py +28 -0
- golf/examples/basic/prompts/welcome.py +30 -0
- golf/examples/basic/resources/current_time.py +41 -0
- golf/examples/basic/resources/info.py +27 -0
- golf/examples/basic/resources/weather/common.py +48 -0
- golf/examples/basic/resources/weather/current.py +32 -0
- golf/examples/basic/resources/weather/forecast.py +32 -0
- golf/examples/basic/tools/github_user.py +67 -0
- golf/examples/basic/tools/hello.py +29 -0
- golf/examples/basic/tools/payments/charge.py +50 -0
- golf/examples/basic/tools/payments/common.py +34 -0
- golf/examples/basic/tools/payments/refund.py +50 -0
- golf_mcp-0.1.0.dist-info/METADATA +78 -0
- golf_mcp-0.1.0.dist-info/RECORD +41 -0
- golf_mcp-0.1.0.dist-info/WHEEL +5 -0
- golf_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- golf_mcp-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|