Skip to content

Commit fec4797

Browse files
committed
Merge branch 'main' of https://github.com/golf-mcp/golf into aschlean/add-tool-name-decorators
2 parents c08c579 + 659b35e commit fec4797

File tree

11 files changed

+587
-99
lines changed

11 files changed

+587
-99
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Golf 0.2.x introduces breaking changes to align with FastMCP 2.11.x:
1313
- **Authentication System**: Complete rewrite using FastMCP's built-in auth providers (JWT, OAuth, Static tokens)
1414
- **Legacy OAuth Removed**: Custom OAuth implementation replaced with standards-compliant FastMCP providers
1515
- **Configuration Changes**: `auth.py` configuration must be updated to use new auth configs (legacy `pre_build.py` supported)
16-
- **Dependency Updates**: Requires FastMCP >=2.11.0
16+
- **Dependency Updates**: Requires FastMCP >=2.14.0
1717
- **Removed Files**: Legacy `oauth.py` and `provider.py` files removed from auth module
1818
- **Deprecated Functions**: `get_provider_token()` and OAuth-related helpers return None (legacy compatibility)
1919

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "golf-mcp"
7-
version = "0.2.19"
7+
version = "0.3.0rc1"
88
description = "Framework for building MCP servers"
99
authors = [
1010
{name = "Antoni Gmitruk", email = "antoni@golf.dev"}
@@ -28,7 +28,7 @@ classifiers = [
2828
dependencies = [
2929
"typer>=0.15.4",
3030
"rich>=14.0.0",
31-
"fastmcp==2.12.5",
31+
"fastmcp>=2.14.0,<2.15.0",
3232
"pydantic>=2.11.0",
3333
"pydantic-settings>=2.0.0",
3434
"python-dotenv>=1.1.0",
@@ -66,7 +66,7 @@ golf = ["examples/**/*"]
6666

6767
[tool.poetry]
6868
name = "golf-mcp"
69-
version = "0.2.19"
69+
version = "0.3.0rc1"
7070
description = "Framework for building MCP servers with zero boilerplate"
7171
authors = ["Antoni Gmitruk <antoni@golf.dev>"]
7272
license = "Apache-2.0"
@@ -85,7 +85,7 @@ classifiers = [
8585

8686
[tool.poetry.dependencies]
8787
python = ">=3.8" # Match requires-python
88-
fastmcp = "==2.12.5"
88+
fastmcp = ">=2.14.0,<2.15.0"
8989
typer = {extras = ["all"], version = ">=0.15.4"}
9090
pydantic = ">=2.11.0"
9191
pydantic-settings = ">=2.0.0"

src/golf/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
__version__ = "0.2.19"
1+
__version__ = "0.3.0rc1"
22

33
from golf.decorators import prompt, resource, tool
44

5-
__all__ = ["tool", "resource", "prompt"]
5+
__all__ = ["tool", "resource", "prompt"]

src/golf/core/builder.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,12 @@ def _generate_startup_section(self, project_path: Path) -> list[str]:
670670
" ",
671671
" except Exception as e:",
672672
" import traceback",
673+
" # Record error to trace if telemetry is available",
674+
" try:",
675+
" from golf.telemetry import record_runtime_error",
676+
" record_runtime_error(e, 'startup_script')",
677+
" except ImportError:",
678+
" pass # Telemetry not available",
673679
" print(f'Warning: Startup script execution failed: {e}', file=sys.stderr)",
674680
" print(traceback.format_exc(), file=sys.stderr)",
675681
" # Continue server startup despite script failure",
@@ -826,6 +832,12 @@ def _generate_check_function_helper(self) -> list[str]:
826832
" except Exception as e:",
827833
" # Log error and return failure response",
828834
" import sys",
835+
" # Record error to trace if telemetry is available",
836+
" try:",
837+
" from golf.telemetry import record_runtime_error",
838+
" record_runtime_error(e, f'{check_type}_check')",
839+
" except ImportError:",
840+
" pass # Telemetry not available",
829841
' print(f"Error calling {check_type} check function: {e}", file=sys.stderr)',
830842
" print(traceback.format_exc(), file=sys.stderr)",
831843
" return JSONResponse({",
@@ -1313,12 +1325,13 @@ def _generate_server(self) -> None:
13131325
middleware_setup.append(" from starlette.middleware import Middleware")
13141326
middleware_list.append("Middleware(MetricsMiddleware)")
13151327

1316-
# Add OpenTelemetry middleware if enabled
1317-
# Note: We intentionally do NOT use opentelemetry.instrumentation.asgi.OpenTelemetryMiddleware
1318-
# because it creates noisy low-level ASGI spans (http receive/send). Golf's FastMCP middleware
1319-
# (OpenTelemetryMiddleware) creates cleaner, more meaningful MCP-level spans.
1328+
# Add OpenTelemetry HTTP tracing middleware if enabled
1329+
# This adds SessionTracingMiddleware for HTTP-level error recording (4xx/5xx responses)
1330+
# The FastMCP-level OpenTelemetryMiddleware is added via mcp.add_middleware() earlier
13201331
if self.settings.opentelemetry_enabled:
1321-
pass # OpenTelemetry middleware is added via mcp.add_middleware() earlier in the code
1332+
middleware_setup.append(" from starlette.middleware import Middleware")
1333+
middleware_setup.append(" from golf.telemetry.instrumentation import SessionTracingMiddleware")
1334+
middleware_list.append("Middleware(SessionTracingMiddleware)")
13221335

13231336
if middleware_setup:
13241337
main_code.extend(middleware_setup)
@@ -1374,12 +1387,13 @@ def _generate_server(self) -> None:
13741387
middleware_setup.append(" from starlette.middleware import Middleware")
13751388
middleware_list.append("Middleware(MetricsMiddleware)")
13761389

1377-
# Add OpenTelemetry middleware if enabled
1378-
# Note: We intentionally do NOT use opentelemetry.instrumentation.asgi.OpenTelemetryMiddleware
1379-
# because it creates noisy low-level ASGI spans (http receive/send). Golf's FastMCP middleware
1380-
# (OpenTelemetryMiddleware) creates cleaner, more meaningful MCP-level spans.
1390+
# Add OpenTelemetry HTTP tracing middleware if enabled
1391+
# This adds SessionTracingMiddleware for HTTP-level error recording (4xx/5xx responses)
1392+
# The FastMCP-level OpenTelemetryMiddleware is added via mcp.add_middleware() earlier
13811393
if self.settings.opentelemetry_enabled:
1382-
pass # OpenTelemetry middleware is added via mcp.add_middleware() earlier in the code
1394+
middleware_setup.append(" from starlette.middleware import Middleware")
1395+
middleware_setup.append(" from golf.telemetry.instrumentation import SessionTracingMiddleware")
1396+
middleware_list.append("Middleware(SessionTracingMiddleware)")
13831397

13841398
if middleware_setup:
13851399
main_code.extend(middleware_setup)
@@ -1814,6 +1828,12 @@ def build_project(
18141828
else:
18151829
console.print("[yellow]Warning: Could not find telemetry instrumentation module[/yellow]")
18161830

1831+
# Copy errors module for runtime error recording
1832+
src_errors = Path(__file__).parent.parent.parent / "golf" / "telemetry" / "errors.py"
1833+
dst_errors = telemetry_dir / "errors.py"
1834+
if src_errors.exists():
1835+
shutil.copy(src_errors, dst_errors)
1836+
18171837
# Check if auth routes need to be added
18181838
if is_auth_configured() or get_api_key_config():
18191839
auth_routes_code = generate_auth_routes()

src/golf/core/builder_auth.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,9 @@ def generate_api_key_auth_components(
182182
def generate_auth_routes() -> str:
183183
"""Generate code for auth routes in the FastMCP app.
184184
185-
Auth providers (RemoteAuthProvider, OAuthProvider) provide OAuth metadata routes
186-
that need to be added to the server.
185+
In FastMCP 2.14+, auth providers automatically register their routes
186+
when passed to the FastMCP constructor via the auth= parameter.
187+
This function uses the public custom_route API as a fallback.
187188
"""
188189
# API key auth doesn't need special routes
189190
api_key_config = get_api_key_config()
@@ -194,16 +195,31 @@ def generate_auth_routes() -> str:
194195
if not is_auth_configured():
195196
return ""
196197

197-
# Auth providers provide OAuth metadata routes that need to be added to the server
198+
# Auth providers in FastMCP 2.14+ auto-register routes when passed to constructor.
199+
# This code provides fallback registration using the public custom_route API.
198200
return """
199-
# Add OAuth metadata routes from auth provider
201+
# Add OAuth metadata routes from auth provider (FastMCP 2.14+ compatible)
202+
# Note: FastMCP 2.14+ automatically registers auth routes when auth provider
203+
# is passed to the constructor. This is a fallback for explicit route registration.
200204
if auth_provider and hasattr(auth_provider, 'get_routes'):
201205
auth_routes = auth_provider.get_routes()
202206
if auth_routes:
203-
# Add routes to FastMCP's additional HTTP routes list
204-
try:
205-
mcp._additional_http_routes.extend(auth_routes)
206-
# Added {len(auth_routes)} OAuth metadata routes
207-
except Exception as e:
208-
print(f"Warning: Failed to add OAuth routes: {e}")
207+
# Register routes using FastMCP's public custom_route API
208+
for route in auth_routes:
209+
try:
210+
path = route.path if hasattr(route, 'path') else str(route)
211+
methods = list(getattr(route, 'methods', ['GET']))
212+
endpoint = getattr(route, 'endpoint', None)
213+
214+
if endpoint:
215+
# Create a closure to capture the endpoint
216+
def make_handler(ep):
217+
async def handler(request):
218+
return await ep(request)
219+
return handler
220+
221+
# Register using public API
222+
mcp.custom_route(path, methods=methods)(make_handler(endpoint))
223+
except Exception as e:
224+
print(f"Warning: Failed to add OAuth route {path}: {e}")
209225
"""

src/golf/core/builder_metrics.py

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -181,41 +181,36 @@ def generate_metrics_instrumentation() -> list[str]:
181181

182182

183183
def generate_session_tracking() -> list[str]:
184-
"""Generate session tracking integration code.
184+
"""Generate session tracking using FastMCP middleware (2.14+ compatible).
185185
186186
Returns:
187-
List of code lines for session tracking
187+
List of code lines for session tracking via middleware
188188
"""
189189
return [
190-
"# Session tracking integration",
191-
"import asyncio",
192-
"from typing import Dict",
190+
"# Session tracking via FastMCP middleware (2.14+ compatible)",
191+
"from fastmcp.server.middleware import Middleware as FastMCPMiddleware, MiddlewareContext, CallNext",
192+
"from typing import Any",
193193
"",
194-
"# Track active sessions",
195-
"_active_sessions: Dict[str, float] = {}",
196-
"",
197-
"# Hook into FastMCP's session lifecycle if available",
198-
"try:",
199-
" from fastmcp.server import SessionManager",
194+
"class SessionTrackingMiddleware(FastMCPMiddleware):",
195+
' """Middleware to track MCP session lifecycle for metrics."""',
200196
" ",
201-
" # Monkey patch session creation if possible",
202-
" _original_create_session = getattr(mcp, '_create_session', None)",
203-
" if _original_create_session:",
204-
" async def _patched_create_session(*args, **kwargs):",
205-
" session_id = str(id(args)) if args else 'unknown'",
206-
" _active_sessions[session_id] = time.time()",
207-
" track_session_start()",
208-
" try:",
209-
" return await _original_create_session(*args, **kwargs)",
210-
" except Exception:",
211-
" # If session creation fails, clean up",
212-
" if session_id in _active_sessions:",
213-
" del _active_sessions[session_id]",
214-
" raise",
197+
" async def on_initialize(",
198+
" self,",
199+
" context: MiddlewareContext[Any],",
200+
" call_next: CallNext[Any, Any],",
201+
" ) -> Any:",
202+
' """Track session initialization."""',
203+
" # Track session start",
204+
" track_session_start()",
215205
" ",
216-
" mcp._create_session = _patched_create_session",
217-
"except (ImportError, AttributeError):",
218-
" # Fallback: track sessions via request patterns",
219-
" pass",
206+
" try:",
207+
" result = await call_next(context)",
208+
" return result",
209+
" except Exception:",
210+
" # Session initialization failed",
211+
" raise",
212+
"",
213+
"# Add session tracking middleware",
214+
"mcp.add_middleware(SessionTrackingMiddleware())",
220215
"",
221216
]

src/golf/telemetry/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
OpenTelemetryMiddleware,
1414
OTelContextCapturingMiddleware,
1515
)
16+
from golf.telemetry.errors import record_http_error, record_runtime_error
1617

1718
__all__ = [
1819
"instrument_tool",
@@ -26,4 +27,6 @@
2627
"get_tracer",
2728
"OpenTelemetryMiddleware",
2829
"OTelContextCapturingMiddleware",
30+
"record_http_error",
31+
"record_runtime_error",
2932
]

src/golf/telemetry/errors.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Utility functions for recording runtime errors to OpenTelemetry traces."""
2+
3+
from typing import Any
4+
5+
from opentelemetry import trace
6+
from opentelemetry.trace import Status, StatusCode
7+
8+
9+
def record_http_error(
10+
status_code: int,
11+
method: str,
12+
path: str,
13+
operation: str = "http_request",
14+
component: str = "golf",
15+
error_message: str | None = None,
16+
attributes: dict[str, Any] | None = None,
17+
) -> None:
18+
"""Record an HTTP error response to the current trace span.
19+
20+
This function safely records HTTP errors (non-200/202 responses) to the current
21+
OpenTelemetry span. Use this for capturing failed OAuth flows, health check
22+
failures, or any other HTTP endpoint that returns an error status.
23+
24+
Args:
25+
status_code: The HTTP status code (e.g., 401, 403, 500, 503)
26+
method: HTTP method (GET, POST, etc.)
27+
path: Request path (e.g., "/oauth/token", "/health")
28+
operation: Name of the operation (e.g., "oauth_token", "health_check")
29+
component: Source component (default: "golf")
30+
error_message: Optional error message to include
31+
attributes: Optional additional attributes to add to the span
32+
33+
Example:
34+
if response.status_code == 401:
35+
record_http_error(401, "POST", "/oauth/token", "oauth_token",
36+
error_message="Invalid credentials")
37+
"""
38+
span = trace.get_current_span()
39+
40+
# Safety check: no span or span not recording
41+
if span is None or not span.is_recording():
42+
return
43+
44+
# Only record errors for 4xx and 5xx status codes
45+
if status_code < 400:
46+
return
47+
48+
# Determine error category
49+
error_category = "client_error" if status_code < 500 else "server_error"
50+
51+
# Build event attributes
52+
event_attrs: dict[str, Any] = {
53+
"http.status_code": status_code,
54+
"http.method": method,
55+
"http.path": path,
56+
"error.category": error_category,
57+
"error.source": component,
58+
"operation": operation,
59+
}
60+
61+
if error_message:
62+
event_attrs["error.message"] = error_message
63+
64+
if attributes:
65+
event_attrs.update({f"error.{k}": str(v) for k, v in attributes.items()})
66+
67+
# Set span status to ERROR
68+
status_description = f"{component}.{operation}: HTTP {status_code}"
69+
if error_message:
70+
status_description += f" - {error_message}"
71+
span.set_status(Status(StatusCode.ERROR, status_description))
72+
73+
# Add HTTP status code attribute
74+
span.set_attribute("http.status_code", status_code)
75+
76+
# Add an error event with structured attributes
77+
span.add_event(f"{component}.http_error", event_attrs)
78+
79+
80+
def record_runtime_error(
81+
error: Exception,
82+
operation: str,
83+
component: str = "golf",
84+
attributes: dict[str, Any] | None = None,
85+
) -> None:
86+
"""Record a runtime error to the current trace span.
87+
88+
This function safely records an error to the current OpenTelemetry span,
89+
if one exists and is recording. It's designed to be called from generated
90+
server code or extension libraries like golf-mcp-enterprise.
91+
92+
Args:
93+
error: The exception that occurred
94+
operation: Name of the operation that failed (e.g., "startup_script", "health_check")
95+
component: Source component (default: "golf", could be "golf-mcp-enterprise")
96+
attributes: Optional additional attributes to add to the span
97+
98+
Example:
99+
try:
100+
run_startup_script()
101+
except Exception as e:
102+
record_runtime_error(e, "startup_script")
103+
print(f"Startup failed: {e}", file=sys.stderr)
104+
"""
105+
span = trace.get_current_span()
106+
107+
# Safety check: no span or span not recording
108+
if span is None or not span.is_recording():
109+
return
110+
111+
# Record the exception with escaped=True since we're not suppressing it
112+
extra_attrs = {
113+
"error.source": component,
114+
"error.operation": operation,
115+
}
116+
if attributes:
117+
extra_attrs.update({f"error.{k}": str(v) for k, v in attributes.items()})
118+
119+
span.record_exception(error, attributes=extra_attrs, escaped=True)
120+
121+
# Set span status to ERROR
122+
span.set_status(Status(StatusCode.ERROR, f"{component}.{operation}: {type(error).__name__}: {error}"))
123+
124+
# Add an error event with structured attributes
125+
span.add_event(
126+
f"{component}.runtime_error",
127+
{
128+
"operation": operation,
129+
"error.type": type(error).__name__,
130+
"error.message": str(error),
131+
},
132+
)

0 commit comments

Comments
 (0)