├── tests ├── auth │ ├── __init__.py │ └── test_api_key.py ├── core │ ├── __init__.py │ ├── test_config.py │ └── test_telemetry.py ├── commands │ ├── __init__.py │ └── test_init.py ├── fixtures │ └── __init__.py ├── __init__.py └── conftest.py ├── src └── golf │ ├── examples │ ├── __init__.py │ └── basic │ │ ├── golf.json │ │ ├── .env.example │ │ ├── resources │ │ ├── info.py │ │ ├── current_time.py │ │ └── weather │ │ │ ├── current.py │ │ │ ├── forecast.py │ │ │ ├── city.py │ │ │ └── client.py │ │ ├── prompts │ │ └── welcome.py │ │ ├── tools │ │ ├── say │ │ │ └── hello.py │ │ └── calculator.py │ │ ├── auth.py │ │ └── README.md │ ├── __init__.py │ ├── cli │ ├── __init__.py │ └── branding.py │ ├── core │ ├── __init__.py │ ├── builder_telemetry.py │ ├── config.py │ ├── builder_auth.py │ ├── builder_metrics.py │ └── transformer.py │ ├── commands │ ├── __init__.py │ ├── build.py │ ├── run.py │ └── init.py │ ├── middleware.py │ ├── metrics │ ├── __init__.py │ ├── registry.py │ └── collector.py │ ├── utilities │ ├── __init__.py │ ├── context.py │ ├── elicitation.py │ └── sampling.py │ ├── telemetry │ └── __init__.py │ └── auth │ ├── api_key.py │ ├── helpers.py │ ├── registry.py │ └── __init__.py ├── golf-banner.png ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md ├── SECURITY.md ├── workflows │ └── test.yml ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── MANIFEST.in ├── pyproject.toml ├── CLAUDE.md ├── README.md └── LICENSE /tests/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/golf/examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/golf/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.19" 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the Golf MCP framework.""" 2 | -------------------------------------------------------------------------------- /src/golf/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """CLI package for the GolfMCP framework.""" 2 | -------------------------------------------------------------------------------- /golf-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golf-mcp/golf/HEAD/golf-banner.png -------------------------------------------------------------------------------- /src/golf/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core functionality for the GolfMCP framework.""" 2 | -------------------------------------------------------------------------------- /src/golf/examples/basic/golf.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic-server-example", 3 | "description": "A GolfMCP project", 4 | "transport": "http" 5 | } -------------------------------------------------------------------------------- /src/golf/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """GolfMCP command implementations.""" 2 | 3 | from golf.commands import build, init, run 4 | 5 | __all__ = ["build", "init", "run"] 6 | -------------------------------------------------------------------------------- /src/golf/examples/basic/.env.example: -------------------------------------------------------------------------------- 1 | # Golf MCP Server Environment Variables 2 | 3 | # Development tokens are configured in auth.py for this example 4 | # Additional environment variables can be added as needed -------------------------------------------------------------------------------- /src/golf/middleware.py: -------------------------------------------------------------------------------- 1 | """Golf middleware support - re-exports FastMCP middleware with Golf branding.""" 2 | 3 | # Re-export FastMCP middleware components for user imports 4 | from fastmcp.server.middleware import Middleware, MiddlewareContext 5 | 6 | # Export commonly used types 7 | __all__ = ["Middleware", "MiddlewareContext"] 8 | -------------------------------------------------------------------------------- /src/golf/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | """Golf metrics module for Prometheus-compatible metrics collection.""" 2 | 3 | from golf.metrics.collector import MetricsCollector, get_metrics_collector 4 | from golf.metrics.registry import init_metrics 5 | 6 | __all__ = [ 7 | "MetricsCollector", 8 | "get_metrics_collector", 9 | "init_metrics", 10 | ] 11 | -------------------------------------------------------------------------------- /src/golf/metrics/registry.py: -------------------------------------------------------------------------------- 1 | """Metrics registry for Golf MCP servers.""" 2 | 3 | from golf.metrics.collector import init_metrics_collector 4 | 5 | 6 | def init_metrics(enabled: bool = False) -> None: 7 | """Initialize the metrics system. 8 | 9 | Args: 10 | enabled: Whether to enable metrics collection 11 | """ 12 | init_metrics_collector(enabled=enabled) 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.docs 2 | /.cursor 3 | __pycache__/ 4 | .pytest_cache/ 5 | *.egg-info/ 6 | pyrightconfig.json 7 | .vscode/ 8 | .env 9 | dist/ 10 | .coverage 11 | Makefile 12 | htmlcov/ 13 | .ruff_cache/ 14 | new/ 15 | dcr/ 16 | .DS_Store 17 | 18 | # Generated endpoints file (created by editable install) 19 | src/golf/_endpoints.py 20 | build.sh 21 | .claude 22 | 23 | thoughts/ 24 | 25 | *.code-workspace -------------------------------------------------------------------------------- /src/golf/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | """Golf utilities for enhanced MCP tool development. 2 | 3 | This module provides convenient utilities for Golf tool authors to access 4 | advanced MCP features like elicitation and sampling without needing to 5 | manage FastMCP Context objects directly. 6 | """ 7 | 8 | from .elicitation import elicit, elicit_confirmation 9 | from .sampling import sample, sample_structured, sample_with_context 10 | from .context import get_current_context 11 | 12 | __all__ = ["elicit", "elicit_confirmation", "sample", "sample_structured", "sample_with_context", "get_current_context"] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: aschlean 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request Template 2 | 3 | ## Description 4 | 5 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. 6 | 7 | Fixes # (issue) 8 | 9 | ## Type of change 10 | 11 | Please delete options that are not relevant. 12 | 13 | - [ ] Bug fix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | - [ ] Documentation update 17 | - [ ] Security enhancement 18 | - [ ] Performance improvement 19 | - [ ] Code refactoring (no functional changes) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[REQUEST]" 5 | labels: enhancement 6 | assignees: aschlean 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/golf/telemetry/__init__.py: -------------------------------------------------------------------------------- 1 | """Golf telemetry module for OpenTelemetry instrumentation.""" 2 | 3 | from golf.telemetry.instrumentation import ( 4 | get_provider, 5 | get_tracer, 6 | init_telemetry, 7 | instrument_elicitation, 8 | instrument_prompt, 9 | instrument_resource, 10 | instrument_sampling, 11 | instrument_tool, 12 | telemetry_lifespan, 13 | OpenTelemetryMiddleware, 14 | OTelContextCapturingMiddleware, 15 | ) 16 | 17 | __all__ = [ 18 | "instrument_tool", 19 | "instrument_resource", 20 | "instrument_prompt", 21 | "instrument_elicitation", 22 | "instrument_sampling", 23 | "telemetry_lifespan", 24 | "init_telemetry", 25 | "get_provider", 26 | "get_tracer", 27 | "OpenTelemetryMiddleware", 28 | "OTelContextCapturingMiddleware", 29 | ] 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include pyproject.toml 4 | graft .docs 5 | graft src/golf/examples 6 | 7 | # Exclude common Python bytecode files and caches 8 | prune **/__pycache__ 9 | global-exclude *.py[co] 10 | 11 | # Exclude build artifacts and local env/dist files if they were accidentally included 12 | prune build 13 | prune dist 14 | prune .eggs 15 | prune *.egg-info 16 | prune .env 17 | prune .venv 18 | 19 | # Exclude development and cache directories 20 | prune .claude 21 | prune htmlcov 22 | prune thoughts 23 | prune new 24 | prune tests 25 | prune .pytest_cache 26 | prune .ruff_cache 27 | prune .git 28 | prune .github 29 | 30 | # Exclude development files 31 | exclude build.sh 32 | exclude setup.py 33 | exclude CLAUDE.md 34 | exclude Makefile 35 | exclude .coverage 36 | exclude .DS_Store 37 | exclude *.code-workspace 38 | exclude golf-banner.png -------------------------------------------------------------------------------- /src/golf/examples/basic/resources/info.py: -------------------------------------------------------------------------------- 1 | """Example resource that provides information about the project.""" 2 | 3 | import platform 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | resource_uri = "info://system" 8 | 9 | 10 | async def info() -> dict[str, Any]: 11 | """Provide system information as a resource. 12 | 13 | This is a simple example resource that demonstrates how to expose 14 | data to an LLM client through the MCP protocol. 15 | """ 16 | return { 17 | "project": "{{project_name}}", 18 | "timestamp": datetime.now().isoformat(), 19 | "platform": { 20 | "system": platform.system(), 21 | "python_version": platform.python_version(), 22 | "architecture": platform.machine(), 23 | }, 24 | } 25 | 26 | 27 | # Designate the entry point function 28 | export = info 29 | -------------------------------------------------------------------------------- /src/golf/examples/basic/prompts/welcome.py: -------------------------------------------------------------------------------- 1 | """Welcome prompt for new users.""" 2 | 3 | 4 | async def welcome() -> list[dict]: 5 | """Provide a welcome prompt for new users. 6 | 7 | This is a simple example prompt that demonstrates how to define 8 | a prompt template in GolfMCP. 9 | """ 10 | return [ 11 | { 12 | "role": "system", 13 | "content": ( 14 | "You are an assistant for the {{project_name}} application. " 15 | "You help users understand how to interact with this system and " 16 | "its capabilities." 17 | ), 18 | }, 19 | { 20 | "role": "user", 21 | "content": ("Welcome to {{project_name}}! This is a project built with GolfMCP. How can I get started?"), 22 | }, 23 | ] 24 | 25 | 26 | # Designate the entry point function 27 | export = welcome 28 | -------------------------------------------------------------------------------- /src/golf/examples/basic/resources/current_time.py: -------------------------------------------------------------------------------- 1 | """Current time resource example.""" 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | # The URI that clients will use to access this resource 7 | resource_uri = "system://time" 8 | 9 | 10 | async def current_time() -> dict[str, Any]: 11 | """Provide the current time in various formats. 12 | 13 | This is a simple resource example that returns time in all formats. 14 | """ 15 | now = datetime.now() 16 | 17 | # Prepare all possible formats 18 | all_formats = { 19 | "iso": now.isoformat(), 20 | "rfc": now.strftime("%a, %d %b %Y %H:%M:%S %z"), 21 | "unix": int(now.timestamp()), 22 | "formatted": { 23 | "date": now.strftime("%Y-%m-%d"), 24 | "time": now.strftime("%H:%M:%S"), 25 | "timezone": now.astimezone().tzname(), 26 | }, 27 | } 28 | 29 | # Return all formats 30 | return all_formats 31 | 32 | 33 | # Designate the entry point function 34 | export = current_time 35 | -------------------------------------------------------------------------------- /src/golf/examples/basic/resources/weather/current.py: -------------------------------------------------------------------------------- 1 | """Current weather resource example.""" 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | from .client import weather_client 7 | 8 | # The URI that clients will use to access this resource 9 | resource_uri = "weather://current" 10 | 11 | 12 | async def current_weather() -> dict[str, Any]: 13 | """Provide current weather for a default city. 14 | 15 | This example demonstrates: 16 | 1. Nested resource organization (resources/weather/current.py) 17 | 2. Resource without URI parameters 18 | 3. Using shared client from the client.py file 19 | """ 20 | # Use the shared weather client from client.py 21 | weather_data = await weather_client.get_current("New York") 22 | 23 | # Add some additional data 24 | weather_data.update( 25 | { 26 | "time": datetime.now().isoformat(), 27 | "source": "GolfMCP Weather API", 28 | "unit": "fahrenheit", 29 | } 30 | ) 31 | 32 | return weather_data 33 | 34 | 35 | # Designate the entry point function 36 | export = current_weather 37 | -------------------------------------------------------------------------------- /src/golf/examples/basic/resources/weather/forecast.py: -------------------------------------------------------------------------------- 1 | """Weather forecast resource example demonstrating nested resources.""" 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | from .client import weather_client 7 | 8 | # The URI that clients will use to access this resource 9 | resource_uri = "weather://forecast" 10 | 11 | 12 | async def forecast_weather() -> dict[str, Any]: 13 | """Provide a weather forecast for a default city. 14 | 15 | This example demonstrates: 16 | 1. Nested resource organization (resources/weather/forecast.py) 17 | 2. Resource without URI parameters 18 | 3. Using shared client from the client.py file 19 | """ 20 | # Use the shared weather client from client.py 21 | forecast_data = await weather_client.get_forecast("New York", days=5) 22 | 23 | # Add some additional data 24 | forecast_data.update( 25 | { 26 | "updated_at": datetime.now().isoformat(), 27 | "source": "GolfMCP Weather API", 28 | "unit": "fahrenheit", 29 | } 30 | ) 31 | 32 | return forecast_data 33 | 34 | 35 | # Designate the entry point function 36 | export = forecast_weather 37 | -------------------------------------------------------------------------------- /src/golf/examples/basic/resources/weather/city.py: -------------------------------------------------------------------------------- 1 | """Weather resource template example with URI parameters.""" 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | from .client import weather_client 7 | 8 | # The URI template that clients will use to access this resource 9 | # The {city} parameter makes this a resource template 10 | resource_uri = "weather://city/{city}" 11 | 12 | 13 | async def get_weather_for_city(city: str) -> dict[str, Any]: 14 | """Provide current weather for a specific city. 15 | 16 | This example demonstrates: 17 | 1. Resource templates with URI parameters ({city}) 18 | 2. Dynamic resource access based on parameters 19 | 3. Using shared client from the client.py file 20 | 4. FastMCP 2.11+ ResourceTemplate.from_function() usage 21 | 22 | Args: 23 | city: The city name to get weather for 24 | 25 | Returns: 26 | Weather data for the specified city 27 | """ 28 | # Use the shared weather client from client.py 29 | weather_data = await weather_client.get_current(city) 30 | 31 | # Add some additional data 32 | weather_data.update( 33 | { 34 | "city": city, 35 | "time": datetime.now().isoformat(), 36 | "source": "GolfMCP Weather API", 37 | "unit": "fahrenheit", 38 | "resource_type": "template", 39 | } 40 | ) 41 | 42 | return weather_data 43 | 44 | 45 | # Designate the entry point function 46 | export = get_weather_for_city 47 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | | Version | Supported | 5 | | ------- | ------------------ | 6 | | 0.2.19 | :white_check_mark: | 7 | | 0.2.18 | :white_check_mark: | 8 | | 0.2.17 | :white_check_mark: | 9 | | 0.2.16 | :white_check_mark: | 10 | | 0.2.15 | :white_check_mark: | 11 | | 0.2.14 | :white_check_mark: | 12 | | 0.2.13 | :white_check_mark: | 13 | | 0.2.12 | :white_check_mark: | 14 | | 0.2.11 | :white_check_mark: | 15 | | 0.2.10 | :white_check_mark: | 16 | | 0.2.9 | :white_check_mark: | 17 | | 0.2.8 | :white_check_mark: | 18 | | 0.2.7 | :white_check_mark: | 19 | | 0.2.6 | :white_check_mark: | 20 | | 0.2.5 | :white_check_mark: | 21 | | 0.2.4 | :white_check_mark: | 22 | | 0.2.3 | :x: | 23 | | 0.2.2 | :white_check_mark: | 24 | | 0.2.1 | :white_check_mark: | 25 | | 0.2.0 | :white_check_mark: | 26 | 27 | 28 | ## Reporting a Vulnerability 29 | 30 | We take the security of Golf seriously. If you believe you've found a security vulnerability, please follow these steps: 31 | 32 | 1. **Do not disclose the vulnerability publicly** or to any third parties. 33 | 34 | 2. **Email us directly** at founders@golf.dev with details of the vulnerability. 35 | 36 | 3. **Include the following information** in your report: 37 | - Description of the vulnerability 38 | - Steps to reproduce 39 | - Potential impact 40 | - Any suggestions for mitigation 41 | 42 | 4. We will acknowledge receipt of your vulnerability report within 48 hours and provide an estimated timeline for a fix. 43 | 44 | 5. Once the vulnerability is fixed, we will notify you and publicly acknowledge your contribution (unless you prefer to remain anonymous). -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: [ main, develop ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | env: 12 | # Ensure telemetry is disabled in CI 13 | GOLF_TELEMETRY: "0" 14 | GOLF_TEST_MODE: "1" 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: "3.11" 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -e ".[telemetry]" 32 | pip install pytest pytest-asyncio pytest-cov 33 | 34 | - name: Run tests 35 | run: | 36 | python -m pytest tests/ -v --cov=golf --cov-report=xml 37 | 38 | - name: Upload coverage to Codecov 39 | uses: codecov/codecov-action@v3 40 | with: 41 | file: ./coverage.xml 42 | fail_ci_if_error: false 43 | 44 | lint: 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | 49 | steps: 50 | - uses: actions/checkout@v3 51 | 52 | - name: Set up Python 53 | uses: actions/setup-python@v4 54 | with: 55 | python-version: "3.11" 56 | 57 | - name: Install dependencies 58 | run: | 59 | python -m pip install --upgrade pip 60 | pip install -e ".[telemetry]" 61 | pip install ruff mypy 62 | 63 | - name: Run ruff format check 64 | run: ruff format --check src/ 65 | 66 | # - name: Run ruff lint 67 | # run: ruff check src/ 68 | 69 | # - name: Run mypy 70 | # run: mypy src/golf --ignore-missing-imports -------------------------------------------------------------------------------- /src/golf/utilities/context.py: -------------------------------------------------------------------------------- 1 | """Context utilities for Golf MCP tools. 2 | 3 | This module provides utilities to access the current FastMCP Context 4 | from within Golf tool functions. 5 | """ 6 | 7 | from typing import TYPE_CHECKING 8 | 9 | if TYPE_CHECKING: 10 | from fastmcp.server.context import Context 11 | 12 | 13 | def get_current_context() -> "Context": 14 | """Get the current FastMCP Context. 15 | 16 | This function retrieves the current FastMCP Context that was injected 17 | into the tool function. It works by importing the FastMCP context 18 | utilities at runtime. 19 | 20 | Returns: 21 | The current FastMCP Context instance 22 | 23 | Raises: 24 | RuntimeError: If called outside of an MCP request context 25 | ImportError: If FastMCP is not available 26 | 27 | Example: 28 | ```python 29 | from golf.utilities import get_current_context 30 | 31 | async def my_tool(data: str): 32 | ctx = get_current_context() 33 | await ctx.info(f"Processing: {data}") 34 | return "done" 35 | ``` 36 | """ 37 | try: 38 | # Import FastMCP context utilities at runtime 39 | from fastmcp.server.context import _current_context 40 | 41 | # Get the current context from the context variable 42 | context = _current_context.get(None) 43 | 44 | if context is None: 45 | raise RuntimeError( 46 | "No FastMCP Context available. This function must be called " 47 | "from within an MCP tool function that has context injection enabled." 48 | ) 49 | 50 | return context 51 | 52 | except ImportError as e: 53 | raise ImportError("FastMCP is not available. Please ensure fastmcp>=2.11.0 is installed.") from e 54 | -------------------------------------------------------------------------------- /src/golf/examples/basic/resources/weather/client.py: -------------------------------------------------------------------------------- 1 | """Weather shared functionality. 2 | 3 | This file demonstrates the recommended pattern for 4 | sharing functionality across multiple resources in a directory. 5 | Golf automatically discovers and includes shared Python files in builds. 6 | """ 7 | 8 | import os 9 | from typing import Any 10 | 11 | # Read configuration from environment variables 12 | WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY", "mock_key") 13 | WEATHER_API_URL = os.environ.get("WEATHER_API_URL", "https://api.example.com/weather") 14 | TEMPERATURE_UNIT = os.environ.get("WEATHER_TEMP_UNIT", "fahrenheit") 15 | 16 | 17 | class WeatherApiClient: 18 | """Mock weather API client.""" 19 | 20 | def __init__(self, api_key: str = WEATHER_API_KEY, api_url: str = WEATHER_API_URL) -> None: 21 | self.api_key = api_key 22 | self.api_url = api_url 23 | self.unit = TEMPERATURE_UNIT 24 | 25 | async def get_forecast(self, city: str, days: int = 3) -> dict[str, Any]: 26 | """Get weather forecast for a city (mock implementation).""" 27 | # This would make an API call in a real implementation 28 | return { 29 | "city": city, 30 | "unit": self.unit, 31 | "forecast": [{"day": i, "temp": 70 + i} for i in range(days)], 32 | } 33 | 34 | async def get_current(self, city: str) -> dict[str, Any]: 35 | """Get current weather for a city (mock implementation).""" 36 | return { 37 | "city": city, 38 | "unit": self.unit, 39 | "temperature": 72, 40 | "conditions": "Sunny", 41 | } 42 | 43 | 44 | # Create a shared weather client that can be imported by all resources in this directory 45 | weather_client = WeatherApiClient() 46 | 47 | # This could also define shared models or other utilities 48 | # that would be common across weather-related resources 49 | -------------------------------------------------------------------------------- /src/golf/examples/basic/tools/say/hello.py: -------------------------------------------------------------------------------- 1 | """Enhanced hello tool with elicitation capabilities.""" 2 | 3 | from typing import Annotated 4 | 5 | from pydantic import BaseModel, Field 6 | from golf.utilities import elicit 7 | 8 | 9 | class Output(BaseModel): 10 | """Response from the hello tool.""" 11 | 12 | message: str 13 | 14 | 15 | async def hello( 16 | name: Annotated[str, Field(description="The name of the person to greet")] = "World", 17 | greeting: Annotated[str, Field(description="The greeting phrase to use")] = "Hello", 18 | personalized: Annotated[ 19 | bool, 20 | Field( 21 | description="Whether to ask for additional personal details to create a personalized greeting", 22 | default=False, 23 | ), 24 | ] = False, 25 | ) -> Output: 26 | """Say hello with optional personalized elicitation. 27 | 28 | This enhanced tool can: 29 | - Provide basic greetings 30 | - Elicit additional personal information for personalized messages 31 | - Demonstrate Golf's elicitation capabilities 32 | 33 | Examples: 34 | - hello("Alice") → "Hello, Alice!" 35 | - hello("Bob", personalized=True) → Asks for details, then personalized greeting 36 | """ 37 | # Basic greeting 38 | basic_message = f"{greeting}, {name}!" 39 | 40 | # If personalized greeting is requested, elicit additional info 41 | if personalized: 42 | try: 43 | # Ask for user's mood 44 | mood = await elicit( 45 | "How are you feeling today?", 46 | ["happy", "excited", "calm", "focused", "creative"], 47 | ) 48 | 49 | # Create personalized message 50 | personalized_message = f"{greeting}, {name}! Hope you're having a {mood} day!" 51 | 52 | return Output(message=personalized_message) 53 | 54 | except Exception as e: 55 | # If elicitation fails, fall back to basic greeting 56 | print(f"Personalization failed: {e}") 57 | return Output(message=f"{basic_message} (personalization unavailable)") 58 | 59 | # Return basic greeting 60 | print(f"{greeting} {name}...") 61 | return Output(message=basic_message) 62 | 63 | 64 | # Designate the entry point function 65 | export = hello 66 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration and shared fixtures for Golf MCP tests.""" 2 | 3 | import shutil 4 | import tempfile 5 | from collections.abc import Generator 6 | from pathlib import Path 7 | 8 | import pytest 9 | 10 | 11 | @pytest.fixture 12 | def temp_dir() -> Generator[Path, None, None]: 13 | """Create a temporary directory for test isolation.""" 14 | temp_path = Path(tempfile.mkdtemp()) 15 | yield temp_path 16 | # Cleanup 17 | shutil.rmtree(temp_path, ignore_errors=True) 18 | 19 | 20 | @pytest.fixture 21 | def sample_project(temp_dir: Path) -> Path: 22 | """Create a minimal Golf project structure for testing.""" 23 | project_dir = temp_dir / "test_project" 24 | project_dir.mkdir() 25 | 26 | # Create golf.json 27 | golf_json = project_dir / "golf.json" 28 | golf_json.write_text( 29 | """{ 30 | "name": "TestProject", 31 | "description": "A test Golf project", 32 | "host": "localhost", 33 | "port": 3000, 34 | "transport": "sse" 35 | }""" 36 | ) 37 | 38 | # Create component directories 39 | (project_dir / "tools").mkdir() 40 | (project_dir / "resources").mkdir() 41 | (project_dir / "prompts").mkdir() 42 | 43 | return project_dir 44 | 45 | 46 | @pytest.fixture 47 | def sample_tool_file(sample_project: Path) -> Path: 48 | """Create a sample tool file.""" 49 | tool_file = sample_project / "tools" / "hello.py" 50 | tool_file.write_text( 51 | '''"""A simple hello tool.""" 52 | 53 | from typing import Annotated 54 | from pydantic import BaseModel, Field 55 | 56 | 57 | class Output(BaseModel): 58 | """Response from the hello tool.""" 59 | message: str 60 | 61 | 62 | async def hello( 63 | name: Annotated[str, Field(description="Name to greet")] = "World" 64 | ) -> Output: 65 | """Say hello to someone.""" 66 | return Output(message=f"Hello, {name}!") 67 | 68 | 69 | export = hello 70 | ''' 71 | ) 72 | return tool_file 73 | 74 | 75 | @pytest.fixture(autouse=True) 76 | def isolate_telemetry(monkeypatch) -> None: 77 | """Isolate telemetry for tests to prevent actual tracking.""" 78 | monkeypatch.setenv("GOLF_TELEMETRY", "0") 79 | # Also prevent any file system telemetry operations 80 | monkeypatch.setenv("HOME", str(Path(tempfile.mkdtemp()))) 81 | -------------------------------------------------------------------------------- /src/golf/auth/api_key.py: -------------------------------------------------------------------------------- 1 | """API Key authentication support for Golf MCP servers. 2 | 3 | This module provides a simple API key pass-through mechanism for Golf servers, 4 | allowing tools to access API keys from request headers and forward them to 5 | upstream services. 6 | """ 7 | 8 | from pydantic import BaseModel, Field 9 | 10 | 11 | class ApiKeyConfig(BaseModel): 12 | """Configuration for API key authentication.""" 13 | 14 | header_name: str = Field("X-API-Key", description="Name of the header containing the API key") 15 | header_prefix: str = Field( 16 | "", 17 | description="Optional prefix to strip from the header value (e.g., 'Bearer ')", 18 | ) 19 | required: bool = Field(True, description="Whether API key is required for all requests") 20 | 21 | 22 | # Global configuration storage 23 | _api_key_config: ApiKeyConfig | None = None 24 | 25 | 26 | def configure_api_key(header_name: str = "X-API-Key", header_prefix: str = "", required: bool = True) -> None: 27 | """Configure API key extraction from request headers. 28 | 29 | This function should be called in auth.py to set up API key handling. 30 | 31 | Args: 32 | header_name: Name of the header containing the API key (default: "X-API-Key") 33 | header_prefix: Optional prefix to strip from the header value (e.g., "Bearer ") 34 | required: Whether API key is required for all requests (default: True) 35 | 36 | Example: 37 | # In auth.py 38 | from golf.auth.api_key import configure_api_key 39 | 40 | # Require API key for all requests 41 | configure_api_key( 42 | header_name="Authorization", 43 | header_prefix="Bearer ", 44 | required=True 45 | ) 46 | 47 | # Or make API key optional (pass-through mode) 48 | configure_api_key( 49 | header_name="Authorization", 50 | header_prefix="Bearer ", 51 | required=False 52 | ) 53 | """ 54 | global _api_key_config 55 | _api_key_config = ApiKeyConfig(header_name=header_name, header_prefix=header_prefix, required=required) 56 | 57 | 58 | def get_api_key_config() -> ApiKeyConfig | None: 59 | """Get the current API key configuration. 60 | 61 | Returns: 62 | The API key configuration if set, None otherwise 63 | """ 64 | return _api_key_config 65 | 66 | 67 | def is_api_key_configured() -> bool: 68 | """Check if API key authentication is configured. 69 | 70 | Returns: 71 | True if API key authentication is configured, False otherwise 72 | """ 73 | return _api_key_config is not None 74 | -------------------------------------------------------------------------------- /src/golf/commands/build.py: -------------------------------------------------------------------------------- 1 | """Build command for GolfMCP. 2 | 3 | This module implements the `golf build` command which generates a standalone 4 | FastMCP application from a GolfMCP project. 5 | """ 6 | 7 | import argparse 8 | from pathlib import Path 9 | 10 | from rich.console import Console 11 | 12 | from golf.core.builder import build_project as core_build_project 13 | from golf.core.config import Settings, load_settings 14 | 15 | console = Console() 16 | 17 | 18 | def build_project( 19 | project_path: Path, 20 | settings: Settings, 21 | output_dir: Path, 22 | build_env: str = "prod", 23 | copy_env: bool = False, 24 | ) -> None: 25 | """Build a standalone FastMCP application from a GolfMCP project. 26 | 27 | Args: 28 | project_path: Path to the project root 29 | settings: Project settings 30 | output_dir: Directory to output the built project 31 | build_env: Build environment ('dev' or 'prod') 32 | copy_env: Whether to copy environment variables to the built app 33 | """ 34 | # Call the centralized build function from core.builder 35 | core_build_project(project_path, settings, output_dir, build_env=build_env, copy_env=copy_env) 36 | 37 | 38 | # Add a main section to run the build_project function when this module is 39 | # executed directly 40 | if __name__ == "__main__": 41 | parser = argparse.ArgumentParser(description="Build a standalone FastMCP application") 42 | parser.add_argument( 43 | "--project-path", 44 | "-p", 45 | type=Path, 46 | default=Path.cwd(), 47 | help="Path to the project root (default: current directory)", 48 | ) 49 | parser.add_argument( 50 | "--output-dir", 51 | "-o", 52 | type=Path, 53 | default=Path.cwd() / "dist", 54 | help="Directory to output the built project (default: ./dist)", 55 | ) 56 | parser.add_argument( 57 | "--build-env", 58 | type=str, 59 | default="prod", 60 | choices=["dev", "prod"], 61 | help="Build environment to use (default: prod)", 62 | ) 63 | parser.add_argument( 64 | "--copy-env", 65 | action="store_true", 66 | help="Copy environment variables to the built application", 67 | ) 68 | 69 | args = parser.parse_args() 70 | 71 | # Load settings from the project path 72 | settings = load_settings(args.project_path) 73 | 74 | # Execute the build 75 | build_project( 76 | args.project_path, 77 | settings, 78 | args.output_dir, 79 | build_env=args.build_env, 80 | copy_env=args.copy_env, 81 | ) 82 | -------------------------------------------------------------------------------- /tests/auth/test_api_key.py: -------------------------------------------------------------------------------- 1 | """Tests for API key authentication.""" 2 | 3 | from golf.auth.api_key import ( 4 | configure_api_key, 5 | get_api_key_config, 6 | is_api_key_configured, 7 | ) 8 | 9 | 10 | class TestAPIKeyConfiguration: 11 | """Test API key configuration functionality.""" 12 | 13 | def test_configure_api_key_basic(self) -> None: 14 | """Test basic API key configuration.""" 15 | # Reset any existing configuration 16 | global _api_key_config 17 | from golf.auth import api_key 18 | 19 | api_key._api_key_config = None 20 | 21 | assert not is_api_key_configured() 22 | 23 | # Configure API key with defaults 24 | configure_api_key() 25 | 26 | assert is_api_key_configured() 27 | config = get_api_key_config() 28 | assert config is not None 29 | assert config.header_name == "X-API-Key" 30 | assert config.header_prefix == "" 31 | assert config.required is True 32 | 33 | def test_configure_api_key_custom(self) -> None: 34 | """Test API key configuration with custom settings.""" 35 | # Reset configuration 36 | from golf.auth import api_key 37 | 38 | api_key._api_key_config = None 39 | 40 | # Configure with custom settings 41 | configure_api_key(header_name="Authorization", header_prefix="Bearer ", required=False) 42 | 43 | assert is_api_key_configured() 44 | config = get_api_key_config() 45 | assert config is not None 46 | assert config.header_name == "Authorization" 47 | assert config.header_prefix == "Bearer " 48 | assert config.required is False 49 | 50 | def test_clear_api_key_configuration(self) -> None: 51 | """Test clearing API key configuration.""" 52 | # Configure first 53 | configure_api_key() 54 | assert is_api_key_configured() 55 | 56 | # Clear configuration by setting to None 57 | from golf.auth import api_key 58 | 59 | api_key._api_key_config = None 60 | 61 | assert not is_api_key_configured() 62 | assert get_api_key_config() is None 63 | 64 | def test_api_key_persistence(self) -> None: 65 | """Test that API key configuration persists across calls.""" 66 | # Reset configuration 67 | from golf.auth import api_key 68 | 69 | api_key._api_key_config = None 70 | 71 | # Configure API key 72 | configure_api_key(header_name="Custom-Key") 73 | 74 | # Check it's still there 75 | assert is_api_key_configured() 76 | config1 = get_api_key_config() 77 | 78 | # Get config again 79 | config2 = get_api_key_config() 80 | 81 | assert config1 == config2 82 | assert config2.header_name == "Custom-Key" 83 | -------------------------------------------------------------------------------- /src/golf/examples/basic/auth.py: -------------------------------------------------------------------------------- 1 | """Authentication configuration for the basic Golf MCP server example. 2 | 3 | This example shows different authentication options available in Golf 0.2.x: 4 | - JWT authentication with static keys or JWKS endpoints (production) 5 | - Static token authentication (development/testing) 6 | - OAuth Server mode (full OAuth 2.0 server) 7 | - Remote Authorization Server integration 8 | """ 9 | 10 | # Example 1: JWT authentication with a static public key 11 | # from golf.auth import configure_auth, JWTAuthConfig 12 | # 13 | # configure_auth( 14 | # JWTAuthConfig( 15 | # public_key_env_var="JWT_PUBLIC_KEY", # PEM-encoded public key 16 | # issuer="https://your-auth-server.com", 17 | # audience="https://your-golf-server.com", 18 | # required_scopes=["read:data"], 19 | # ) 20 | # ) 21 | 22 | # Example 2: JWT authentication with JWKS (recommended for production) 23 | # from golf.auth import configure_auth, JWTAuthConfig 24 | # 25 | # configure_auth( 26 | # JWTAuthConfig( 27 | # jwks_uri_env_var="JWKS_URI", # e.g., "https://your-domain.auth0.com/.well-known/jwks.json" 28 | # issuer_env_var="JWT_ISSUER", # e.g., "https://your-domain.auth0.com/" 29 | # audience_env_var="JWT_AUDIENCE", # e.g., "https://your-api.example.com" 30 | # required_scopes=["read:user"], 31 | # ) 32 | # ) 33 | 34 | # Example 3: OAuth Server mode - Golf acts as full OAuth 2.0 authorization server 35 | # from golf.auth import configure_auth, OAuthServerConfig 36 | # 37 | # configure_auth( 38 | # OAuthServerConfig( 39 | # base_url_env_var="OAUTH_BASE_URL", # e.g., "https://auth.example.com" 40 | # valid_scopes=["read", "write", "admin"], # Scopes clients can request 41 | # default_scopes=["read"], # Default scopes for new clients 42 | # required_scopes=["read"], # Scopes required for all requests 43 | # ) 44 | # ) 45 | 46 | # Example 4: Remote Authorization Server integration 47 | # from golf.auth import configure_auth, RemoteAuthConfig, JWTAuthConfig 48 | # 49 | # configure_auth( 50 | # RemoteAuthConfig( 51 | # authorization_servers_env_var="AUTH_SERVERS", # Comma-separated: "https://auth1.com,https://auth2.com" 52 | # resource_server_url_env_var="RESOURCE_URL", # This server's URL 53 | # token_verifier_config=JWTAuthConfig( 54 | # jwks_uri_env_var="JWKS_URI" 55 | # ), 56 | # ) 57 | # ) 58 | 59 | # Example 5: Static token authentication for development (NOT for production) 60 | from golf.auth import configure_auth, StaticTokenConfig 61 | 62 | configure_auth( 63 | StaticTokenConfig( 64 | tokens={ 65 | "dev-token-123": { 66 | "client_id": "dev-client", 67 | "scopes": ["read", "write"], 68 | }, 69 | "admin-token-456": { 70 | "client_id": "admin-client", 71 | "scopes": ["read", "write", "admin"], 72 | }, 73 | }, 74 | required_scopes=["read"], 75 | ) 76 | ) 77 | -------------------------------------------------------------------------------- /src/golf/examples/basic/tools/calculator.py: -------------------------------------------------------------------------------- 1 | """Enhanced calculator tool with optional LLM-powered explanations.""" 2 | 3 | from typing import Annotated 4 | 5 | from pydantic import BaseModel, Field 6 | from golf.utilities import sample 7 | 8 | 9 | class CalculationResult(BaseModel): 10 | """Result of a mathematical calculation.""" 11 | 12 | result: float 13 | operation: str 14 | expression: str 15 | 16 | 17 | async def calculate( 18 | expression: Annotated[ 19 | str, 20 | Field( 21 | description="Mathematical expression to evaluate (e.g., '2 + 3', '10 * 5', '100 / 4')", 22 | examples=["2 + 3", "10 * 5.5", "(8 - 3) * 2"], 23 | ), 24 | ], 25 | explain: Annotated[ 26 | bool, 27 | Field( 28 | description="Whether to provide an LLM-powered step-by-step explanation", 29 | default=False, 30 | ), 31 | ] = False, 32 | ) -> CalculationResult: 33 | """Evaluate a mathematical expression with optional LLM explanation. 34 | 35 | This enhanced calculator can: 36 | - Perform basic arithmetic operations (+, -, *, /, parentheses) 37 | - Handle decimal numbers 38 | - Optionally provide LLM-powered step-by-step explanations 39 | 40 | Examples: 41 | - calculate("2 + 3") → 5 42 | - calculate("10 * 5.5") → 55.0 43 | - calculate("(8 - 3) * 2", explain=True) → 10 with explanation 44 | """ 45 | try: 46 | # Simple expression evaluation using eval (safe for basic math) 47 | # In production, consider using a proper math expression parser 48 | allowed_chars = set("0123456789+-*/.() ") 49 | if not all(c in allowed_chars for c in expression): 50 | raise ValueError("Expression contains invalid characters") 51 | 52 | # Evaluate the expression 53 | result = eval(expression, {"__builtins__": {}}, {}) 54 | 55 | # Ensure result is a number 56 | if not isinstance(result, (int, float)): 57 | raise ValueError("Expression did not evaluate to a number") 58 | 59 | # Generate explanation if requested 60 | result_expression = expression 61 | if explain: 62 | try: 63 | explanation = await sample( 64 | f"Explain this mathematical expression step by step: {expression} = {result}", 65 | system_prompt="You are a helpful math tutor. Provide clear, step-by-step explanations.", 66 | max_tokens=200, 67 | ) 68 | result_expression = f"{expression}\n\nExplanation: {explanation}" 69 | except Exception: 70 | # If sampling fails, continue without explanation 71 | result_expression = f"{expression}\n\n(Explanation unavailable)" 72 | 73 | return CalculationResult( 74 | result=float(result), 75 | operation="evaluate", 76 | expression=result_expression, 77 | ) 78 | 79 | except ZeroDivisionError: 80 | return CalculationResult( 81 | result=float("inf"), 82 | operation="error", 83 | expression=f"{expression} → Division by zero", 84 | ) 85 | except Exception as e: 86 | return CalculationResult( 87 | result=0.0, 88 | operation="error", 89 | expression=f"{expression} → Error: {str(e)}", 90 | ) 91 | 92 | 93 | # Export the tool 94 | export = calculate 95 | -------------------------------------------------------------------------------- /src/golf/core/builder_telemetry.py: -------------------------------------------------------------------------------- 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 | 8 | def generate_telemetry_imports() -> list[str]: 9 | """Generate import statements for telemetry instrumentation. 10 | 11 | Returns: 12 | List of import statements for telemetry 13 | """ 14 | return [ 15 | "# OpenTelemetry instrumentation imports", 16 | "from golf.telemetry import (", 17 | " instrument_tool,", 18 | " instrument_resource,", 19 | " instrument_prompt,", 20 | " telemetry_lifespan,", 21 | ")", 22 | ] 23 | 24 | 25 | def generate_component_registration_with_telemetry( 26 | component_type: str, 27 | component_name: str, 28 | module_path: str, 29 | entry_function: str, 30 | docstring: str = "", 31 | uri_template: str = None, 32 | is_template: bool = False, 33 | ) -> str: 34 | """Generate component registration code with telemetry instrumentation. 35 | 36 | Args: 37 | component_type: Type of component ('tool', 'resource', 'prompt') 38 | component_name: Name of the component 39 | module_path: Full module path to the component 40 | entry_function: Entry function name 41 | docstring: Component description 42 | uri_template: URI template for resources (optional) 43 | is_template: Whether the resource is a template (has URI parameters) 44 | 45 | Returns: 46 | Python code string for registering the component with instrumentation 47 | """ 48 | func_ref = f"{module_path}.{entry_function}" 49 | escaped_docstring = repr(docstring) if docstring else '""' 50 | 51 | if component_type == "tool": 52 | wrapped_func = f"instrument_tool({func_ref}, '{component_name}')" 53 | return ( 54 | f"_tool = Tool.from_function({wrapped_func}, " 55 | f'name="{component_name}", description={escaped_docstring})\n' 56 | f"mcp.add_tool(_tool)" 57 | ) 58 | 59 | elif component_type == "resource": 60 | wrapped_func = f"instrument_resource({func_ref}, '{uri_template}')" 61 | if is_template: 62 | return ( 63 | f"_resource = ResourceTemplate.from_function({wrapped_func}, " 64 | f'uri_template="{uri_template}", name="{component_name}", ' 65 | f"description={escaped_docstring})\n" 66 | f"mcp.add_template(_resource)" 67 | ) 68 | else: 69 | return ( 70 | f"_resource = Resource.from_function({wrapped_func}, " 71 | f'uri="{uri_template}", name="{component_name}", ' 72 | f"description={escaped_docstring})\n" 73 | f"mcp.add_resource(_resource)" 74 | ) 75 | 76 | elif component_type == "prompt": 77 | wrapped_func = f"instrument_prompt({func_ref}, '{component_name}')" 78 | return ( 79 | f"_prompt = Prompt.from_function({wrapped_func}, " 80 | f'name="{component_name}", description={escaped_docstring})\n' 81 | f"mcp.add_prompt(_prompt)" 82 | ) 83 | 84 | else: 85 | raise ValueError(f"Unknown component type: {component_type}") 86 | 87 | 88 | def get_otel_dependencies() -> list[str]: 89 | """Get list of OpenTelemetry dependencies to add to pyproject.toml. 90 | 91 | Returns: 92 | List of package requirements strings 93 | """ 94 | return [ 95 | "opentelemetry-api>=1.18.0", 96 | "opentelemetry-sdk>=1.18.0", 97 | "opentelemetry-instrumentation-asgi>=0.40b0", 98 | "opentelemetry-exporter-otlp-proto-http>=0.40b0", 99 | ] 100 | -------------------------------------------------------------------------------- /src/golf/examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Golf MCP Project Template (Basic) 2 | 3 | This is a basic template for creating MCP servers with Golf. It includes development authentication for easy testing. Use `golf init ` to bootstrap new projects from this template. 4 | 5 | ## About Golf 6 | 7 | Golf is a Python framework for building MCP (Model Context Protocol) servers with minimal boilerplate. Define your server's capabilities as simple Python files, and Golf automatically discovers and compiles them into a runnable FastMCP server. 8 | 9 | ## Getting Started 10 | 11 | After initializing your project: 12 | 13 | 1. **Navigate to your project directory:** 14 | ```bash 15 | cd your-project-name 16 | ``` 17 | 18 | 2. **Configure authentication (optional):** 19 | This template includes development authentication in `auth.py` with sample tokens. Edit the file to set up JWT, OAuth, or API key authentication for production use. 20 | 21 | 3. **Build and run your server:** 22 | ```bash 23 | golf build dev # Development build 24 | golf run # Start the server 25 | ``` 26 | 27 | ## Project Structure 28 | 29 | ``` 30 | your-project/ 31 | ├── tools/ # Tool implementations (functions LLMs can call) 32 | ├── resources/ # Resource implementations (data LLMs can read) 33 | ├── prompts/ # Prompt templates (conversation structures) 34 | ├── golf.json # Server configuration 35 | └── auth.py # Authentication setup 36 | ``` 37 | 38 | ## Adding Components 39 | 40 | ### Tools 41 | Create `.py` files in `tools/` directory. Each file should export a single async function: 42 | 43 | ```python 44 | # tools/calculator.py 45 | async def add(a: int, b: int) -> int: 46 | """Add two numbers together.""" 47 | return a + b 48 | 49 | export = add 50 | ``` 51 | 52 | ### Resources 53 | Create `.py` files in `resources/` directory with a `resource_uri` and export function: 54 | 55 | ```python 56 | # resources/status.py 57 | resource_uri = "status://server" 58 | 59 | async def status() -> dict: 60 | """Get server status information.""" 61 | return {"status": "running", "timestamp": "2024-01-01T00:00:00Z"} 62 | 63 | export = status 64 | ``` 65 | 66 | ### Prompts 67 | Create `.py` files in `prompts/` directory that return message lists: 68 | 69 | ```python 70 | # prompts/assistant.py 71 | async def assistant() -> list[dict]: 72 | """System prompt for a helpful assistant.""" 73 | return [ 74 | { 75 | "role": "system", 76 | "content": "You are a helpful assistant for {{project_name}}." 77 | } 78 | ] 79 | 80 | export = assistant 81 | ``` 82 | 83 | ## Authentication Examples 84 | 85 | ### No Authentication (Default) 86 | Leave `auth.py` empty or remove it entirely. 87 | 88 | ### API Key Authentication 89 | ```python 90 | # auth.py 91 | from golf.auth import configure_api_key 92 | 93 | configure_api_key( 94 | header_name="Authorization", 95 | header_prefix="Bearer ", 96 | required=True 97 | ) 98 | ``` 99 | 100 | ### JWT Authentication 101 | ```python 102 | # auth.py 103 | from golf.auth import configure_jwt_auth 104 | 105 | configure_jwt_auth( 106 | jwks_uri="https://your-domain.auth0.com/.well-known/jwks.json", 107 | issuer="https://your-domain.auth0.com/", 108 | audience="https://your-api.example.com" 109 | ) 110 | ``` 111 | 112 | ### Development Tokens 113 | ```python 114 | # auth.py 115 | from golf.auth import configure_dev_auth 116 | 117 | configure_dev_auth( 118 | tokens={ 119 | "dev-token-123": { 120 | "client_id": "dev-client", 121 | "scopes": ["read", "write"] 122 | } 123 | } 124 | ) 125 | ``` 126 | 127 | ## Documentation 128 | 129 | For comprehensive documentation, visit: [https://docs.golf.dev](https://docs.golf.dev) 130 | 131 | --- 132 | 133 | Happy building! 🏌️‍♂️ -------------------------------------------------------------------------------- /tests/commands/test_init.py: -------------------------------------------------------------------------------- 1 | """Tests for the golf init command.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from golf.commands.init import initialize_project 9 | 10 | 11 | class TestInitCommand: 12 | """Test the init command functionality.""" 13 | 14 | def test_creates_basic_project_structure(self, temp_dir: Path) -> None: 15 | """Test that init creates the expected project structure.""" 16 | project_dir = temp_dir / "my_project" 17 | 18 | initialize_project("my_project", project_dir) 19 | 20 | # Check directory structure 21 | assert project_dir.exists() 22 | assert (project_dir / "golf.json").exists() 23 | assert (project_dir / "tools").is_dir() 24 | assert (project_dir / "resources").is_dir() 25 | assert (project_dir / "prompts").is_dir() 26 | assert (project_dir / ".gitignore").exists() 27 | 28 | def test_golf_json_has_correct_content(self, temp_dir: Path) -> None: 29 | """Test that golf.json is created with correct content.""" 30 | project_dir = temp_dir / "test_project" 31 | 32 | initialize_project("test_project", project_dir) 33 | 34 | config = json.loads((project_dir / "golf.json").read_text()) 35 | assert config["name"] == "basic-server-example" 36 | assert "description" in config 37 | assert config["transport"] == "http" 38 | 39 | def test_template_variable_substitution(self, temp_dir: Path) -> None: 40 | """Test that template files are copied correctly.""" 41 | project_dir = temp_dir / "MyApp" 42 | 43 | initialize_project("MyApp", project_dir) 44 | 45 | # Check that golf.json has the template content 46 | config = json.loads((project_dir / "golf.json").read_text()) 47 | assert config["name"] == "basic-server-example" 48 | assert "description" in config 49 | 50 | # Check .env file exists (no longer has GOLF_NAME) 51 | 52 | def test_handles_existing_empty_directory(self, temp_dir: Path) -> None: 53 | """Test that init works with an existing empty directory.""" 54 | project_dir = temp_dir / "existing" 55 | project_dir.mkdir() 56 | 57 | # Should not raise an error 58 | initialize_project("existing", project_dir) 59 | 60 | assert (project_dir / "golf.json").exists() 61 | 62 | @pytest.mark.skip(reason="Requires interactive input handling") 63 | def test_prompts_for_non_empty_directory(self, temp_dir: Path) -> None: 64 | """Test that init prompts when directory is not empty.""" 65 | # This would require mocking the Confirm.ask prompt 66 | pass 67 | 68 | def test_basic_template_includes_health_check(self, temp_dir: Path) -> None: 69 | """Test that basic template does not include health check configuration by default.""" 70 | project_dir = temp_dir / "health_check_project" 71 | 72 | initialize_project("health_check_project", project_dir) 73 | 74 | # Check that golf.json has basic template content 75 | config = json.loads((project_dir / "golf.json").read_text()) 76 | assert "health_check_enabled" not in config # Should not be included by default 77 | assert "health_check_path" not in config 78 | assert "health_check_response" not in config 79 | 80 | # Should include the basic configuration fields 81 | assert config["name"] == "basic-server-example" 82 | assert "description" in config 83 | assert config["transport"] == "http" 84 | 85 | def test_basic_template_compatibility_with_health_check(self, temp_dir: Path) -> None: 86 | """Test that basic template is compatible with health check configuration.""" 87 | project_dir = temp_dir / "health_check_project" 88 | 89 | initialize_project("health_check_project", project_dir) 90 | 91 | # Check that we can add health check configuration 92 | config_file = project_dir / "golf.json" 93 | config = json.loads(config_file.read_text()) 94 | 95 | # Add health check configuration 96 | config.update( 97 | { 98 | "health_check_enabled": True, 99 | "health_check_path": "/status", 100 | "health_check_response": "API Ready", 101 | } 102 | ) 103 | 104 | # Should be able to write back without issues 105 | config_file.write_text(json.dumps(config, indent=2)) 106 | 107 | # Verify it can be read back 108 | updated_config = json.loads(config_file.read_text()) 109 | assert updated_config["health_check_enabled"] is True 110 | assert updated_config["health_check_path"] == "/status" 111 | assert updated_config["health_check_response"] == "API Ready" 112 | -------------------------------------------------------------------------------- /src/golf/commands/run.py: -------------------------------------------------------------------------------- 1 | """Command to run the built FastMCP server.""" 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | from pathlib import Path 7 | 8 | from rich.console import Console, Group 9 | from rich.panel import Panel 10 | from rich.align import Align 11 | from rich.text import Text 12 | 13 | from golf.cli.branding import create_command_header, get_status_text, STATUS_ICONS, GOLF_BLUE, GOLF_GREEN, GOLF_ORANGE 14 | from golf.core.config import Settings 15 | 16 | console = Console() 17 | 18 | 19 | def run_server( 20 | project_path: Path, 21 | settings: Settings, 22 | dist_dir: Path | None = None, 23 | host: str | None = None, 24 | port: int | None = None, 25 | ) -> int: 26 | """Run the built FastMCP server. 27 | 28 | Args: 29 | project_path: Path to the project root 30 | settings: Project settings 31 | dist_dir: Path to the directory containing the built server 32 | (defaults to project_path/dist) 33 | host: Host to bind the server to (overrides settings) 34 | port: Port to bind the server to (overrides settings) 35 | 36 | Returns: 37 | Process return code 38 | """ 39 | # Set default dist directory if not specified 40 | if dist_dir is None: 41 | dist_dir = project_path / "dist" 42 | 43 | # Check if server file exists 44 | server_path = dist_dir / "server.py" 45 | if not server_path.exists(): 46 | console.print(get_status_text("error", f"Server file {server_path} not found")) 47 | return 1 48 | 49 | # Display server startup header 50 | create_command_header("Starting Server", f"{settings.name}", console) 51 | 52 | # Show server info with flashy styling 53 | server_host = host or settings.host or "localhost" 54 | server_port = port or settings.port or 3000 55 | 56 | # Create server URL line 57 | server_line = Text() 58 | server_line.append("🚀 ", style=f"bold {GOLF_ORANGE}") 59 | server_line.append(f"{STATUS_ICONS['server']} Server starting on ", style=f"bold {GOLF_BLUE}") 60 | server_line.append(f"http://{server_host}:{server_port}", style=f"bold {GOLF_GREEN}") 61 | 62 | # Create content with proper alignment 63 | content_lines = [ 64 | "", # Empty line at top 65 | Align.center(server_line), 66 | ] 67 | 68 | # Add telemetry status indicator 69 | if settings.opentelemetry_enabled: 70 | telemetry_line = Text("📊 OpenTelemetry enabled", style=f"dim {GOLF_BLUE}") 71 | content_lines.append(Align.center(telemetry_line)) 72 | 73 | # Add empty line and stop instruction 74 | content_lines.extend( 75 | [ 76 | "", # Empty line before stop instruction 77 | Align.center(Text("⚡ Press Ctrl+C to stop ⚡", style=f"dim {GOLF_ORANGE}")), 78 | "", # Empty line at bottom 79 | ] 80 | ) 81 | 82 | console.print( 83 | Panel( 84 | Group(*content_lines), 85 | border_style=GOLF_BLUE, 86 | padding=(1, 2), 87 | title="[bold]🌐 SERVER READY 🌐[/bold]", 88 | title_align="center", 89 | ) 90 | ) 91 | console.print() 92 | 93 | # Prepare environment variables 94 | env = os.environ.copy() 95 | if host is not None: 96 | env["HOST"] = host 97 | elif settings.host: 98 | env["HOST"] = settings.host 99 | 100 | if port is not None: 101 | env["PORT"] = str(port) 102 | elif settings.port: 103 | env["PORT"] = str(settings.port) 104 | 105 | # Run the server 106 | try: 107 | # Using subprocess to properly handle signals (Ctrl+C) 108 | process = subprocess.run( 109 | [sys.executable, str(server_path)], 110 | cwd=dist_dir, 111 | env=env, 112 | ) 113 | 114 | # Provide more context about the exit 115 | console.print() 116 | if process.returncode == 0: 117 | console.print(get_status_text("success", "Server stopped successfully")) 118 | elif process.returncode == 130: 119 | console.print(get_status_text("info", "Server stopped by user interrupt (Ctrl+C)")) 120 | elif process.returncode == 143: 121 | console.print(get_status_text("info", "Server stopped by SIGTERM (graceful shutdown)")) 122 | elif process.returncode == 137: 123 | console.print(get_status_text("warning", "Server stopped by SIGKILL (forced shutdown)")) 124 | elif process.returncode in [1, 2]: 125 | console.print(get_status_text("error", f"Server exited with error code {process.returncode}")) 126 | else: 127 | console.print(get_status_text("warning", f"Server exited with code {process.returncode}")) 128 | 129 | return process.returncode 130 | except KeyboardInterrupt: 131 | console.print() 132 | console.print(get_status_text("info", "Server stopped by user (Ctrl+C)")) 133 | return 130 # Standard exit code for SIGINT 134 | except Exception as e: 135 | console.print() 136 | console.print(get_status_text("error", f"Error running server: {e}")) 137 | return 1 138 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=80.8.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "golf-mcp" 7 | version = "0.2.19" 8 | description = "Framework for building MCP servers" 9 | authors = [ 10 | {name = "Antoni Gmitruk", email = "antoni@golf.dev"} 11 | ] 12 | readme = "README.md" 13 | requires-python = ">=3.8" 14 | license = "Apache-2.0" 15 | license-files = ["LICENSE"] 16 | classifiers = [ 17 | "Programming Language :: Python :: 3", 18 | "Operating System :: OS Independent", 19 | "Development Status :: 3 - Alpha", 20 | "Intended Audience :: Developers", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12" 27 | ] 28 | dependencies = [ 29 | "typer>=0.15.4", 30 | "rich>=14.0.0", 31 | "fastmcp==2.12.5", 32 | "pydantic>=2.11.0", 33 | "pydantic-settings>=2.0.0", 34 | "python-dotenv>=1.1.0", 35 | "black>=24.10.0", 36 | "pyjwt>=2.0.0", 37 | "httpx>=0.28.1", 38 | "posthog>=4.1.0", 39 | "opentelemetry-api>=1.33.1", 40 | "opentelemetry-sdk>=1.33.1", 41 | "opentelemetry-instrumentation-asgi>=0.40b0", 42 | "opentelemetry-exporter-otlp-proto-http>=0.40b0", 43 | "wrapt>=1.17.0" 44 | ] 45 | 46 | [project.optional-dependencies] 47 | metrics = [ 48 | "prometheus-client>=0.22.1" 49 | ] 50 | 51 | [project.scripts] 52 | golf = "golf.cli.main:app" 53 | 54 | [project.urls] 55 | "Homepage" = "https://golf.dev" 56 | "Repository" = "https://github.com/golf-mcp/golf" 57 | 58 | [tool.setuptools] 59 | package-dir = {"" = "src"} 60 | [tool.setuptools.packages.find] 61 | where = ["src"] 62 | include = ["golf*"] 63 | exclude = ["golf.tests*"] # Example: if you have tests inside src/golf/tests 64 | [tool.setuptools.package-data] 65 | golf = ["examples/**/*"] 66 | 67 | [tool.poetry] 68 | name = "golf-mcp" 69 | version = "0.2.19" 70 | description = "Framework for building MCP servers with zero boilerplate" 71 | authors = ["Antoni Gmitruk "] 72 | license = "Apache-2.0" 73 | readme = "README.md" 74 | repository = "https://github.com/golf-mcp/golf" 75 | homepage = "https://golf.dev" 76 | packages = [{include = "golf", from = "src"}] 77 | classifiers = [ 78 | "Development Status :: 3 - Alpha", 79 | "Intended Audience :: Developers", 80 | "Topic :: Software Development :: Libraries :: Python Modules", 81 | "Programming Language :: Python :: 3", 82 | "Programming Language :: Python :: 3.11", 83 | "Programming Language :: Python :: 3.12" 84 | ] 85 | 86 | [tool.poetry.dependencies] 87 | python = ">=3.8" # Match requires-python 88 | fastmcp = "==2.12.5" 89 | typer = {extras = ["all"], version = ">=0.15.4"} 90 | pydantic = ">=2.11.0" 91 | pydantic-settings = ">=2.0.0" 92 | rich = ">=14.0.0" 93 | python-dotenv = ">=1.1.0" 94 | black = ">=24.10.0" 95 | pyjwt = ">=2.0.0" 96 | httpx = ">=0.28.1" 97 | posthog = ">=4.1.0" 98 | opentelemetry-api = ">=1.33.1" 99 | opentelemetry-sdk = ">=1.33.1" 100 | opentelemetry-instrumentation-asgi = ">=0.40b0" 101 | opentelemetry-exporter-otlp-proto-http = ">=0.40b0" 102 | wrapt = ">=1.17.0" 103 | prometheus-client = {version = ">=0.22.1", optional = true} 104 | 105 | [tool.poetry.extras] 106 | metrics = ["prometheus-client"] 107 | 108 | [tool.poetry.group.dev.dependencies] 109 | pytest = "^7.4.0" 110 | pytest-asyncio = "^0.23.0" 111 | ruff = "^0.1.0" 112 | mypy = "^1.6.0" 113 | pytest-cov = "^4.1.0" 114 | 115 | [tool.black] 116 | line-length = 120 117 | 118 | [tool.ruff] 119 | line-length = 120 120 | target-version = "py311" 121 | 122 | [tool.ruff.lint] 123 | select = ["E", "F", "B", "C4", "C90", "UP", "N", "ANN", "SIM", "TID"] 124 | ignore = [ 125 | "ANN401", # Disallow Any (too strict for dynamic code) 126 | "B008", # Function call in defaults (common in CLI frameworks like Typer) 127 | ] 128 | 129 | [tool.ruff.format] 130 | # Use Black-compatible formatting 131 | quote-style = "double" 132 | indent-style = "space" 133 | skip-magic-trailing-comma = false 134 | line-ending = "auto" 135 | 136 | [tool.mypy] 137 | python_version = "3.11" 138 | disallow_untyped_defs = true 139 | disallow_incomplete_defs = true 140 | check_untyped_defs = true 141 | disallow_untyped_decorators = true 142 | no_implicit_optional = true 143 | strict_optional = true 144 | warn_redundant_casts = true 145 | warn_unused_ignores = true 146 | warn_return_any = true 147 | warn_unused_configs = true 148 | 149 | [tool.pytest.ini_options] 150 | testpaths = ["tests"] 151 | python_files = ["test_*.py", "*_test.py"] 152 | python_classes = ["Test*"] 153 | python_functions = ["test_*"] 154 | asyncio_mode = "auto" 155 | asyncio_default_fixture_loop_scope = "function" 156 | addopts = [ 157 | "-v", 158 | "--strict-markers", 159 | "--tb=short", 160 | "--cov=golf", 161 | "--cov-report=term-missing", 162 | "--cov-report=html", 163 | ] 164 | markers = [ 165 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 166 | "integration: marks tests as integration tests", 167 | ] -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct - Golf 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behaviour that contributes to a positive environment for our 15 | community include: 16 | 17 | * Demonstrating empathy and kindness toward other people 18 | * Being respectful of differing opinions, viewpoints, and experiences 19 | * Giving and gracefully accepting constructive feedback 20 | * Accepting responsibility and apologising to those affected by our mistakes, 21 | and learning from the experience 22 | * Focusing on what is best not just for us as individuals, but for the 23 | overall community 24 | 25 | Examples of unacceptable behaviour include: 26 | 27 | * The use of sexualised language or imagery, and sexual attention or advances 28 | * Trolling, insulting or derogatory comments, and personal or political attacks 29 | * Public or private harassment 30 | * Publishing others' private information, such as a physical or email 31 | address, without their explicit permission 32 | * Other conduct which could reasonably be considered inappropriate in a 33 | professional setting 34 | 35 | ## Our Responsibilities 36 | 37 | Project maintainers are responsible for clarifying and enforcing our standards of 38 | acceptable behaviour and will take appropriate and fair corrective action in 39 | response to any behaviour that they deem inappropriate, 40 | threatening, offensive, or harmful. 41 | 42 | Project maintainers have the right and responsibility to remove, edit, or reject 43 | comments, commits, code, wiki edits, issues, and other contributions that are 44 | not aligned to this Code of Conduct, and will 45 | communicate reasons for moderation decisions when appropriate. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies within all community spaces, and also applies when 50 | an individual is officially representing the community in public spaces. 51 | Examples of representing our community include using an official e-mail address, 52 | posting via an official social media account, or acting as an appointed 53 | representative at an online or offline event. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behaviour may be 58 | reported to the community leaders responsible for enforcement at . 59 | All complaints will be reviewed and investigated promptly and fairly. 60 | 61 | All community leaders are obligated to respect the privacy and security of the 62 | reporter of any incident. 63 | 64 | ## Enforcement Guidelines 65 | 66 | Community leaders will follow these Community Impact Guidelines in determining 67 | the consequences for any action they deem in violation of this Code of Conduct: 68 | 69 | ### 1. Correction 70 | 71 | **Community Impact**: Use of inappropriate language or other behaviour deemed 72 | unprofessional or unwelcome in the community. 73 | 74 | **Consequence**: A private, written warning from community leaders, providing 75 | clarity around the nature of the violation and an explanation of why the 76 | behaviour was inappropriate. A public apology may be requested. 77 | 78 | ### 2. Warning 79 | 80 | **Community Impact**: A violation through a single incident or series 81 | of actions. 82 | 83 | **Consequence**: A warning with consequences for continued behaviour. No 84 | interaction with the people involved, including unsolicited interaction with 85 | those enforcing the Code of Conduct, for a specified period of time. This 86 | includes avoiding interactions in community spaces as well as external channels 87 | like social media. Violating these terms may lead to a temporary or 88 | permanent ban. 89 | 90 | ### 3. Temporary Ban 91 | 92 | **Community Impact**: A serious violation of community standards, including 93 | sustained inappropriate behaviour. 94 | 95 | **Consequence**: A temporary ban from any sort of interaction or public 96 | communication with the community for a specified period of time. No public or 97 | private interaction with the people involved, including unsolicited interaction 98 | with those enforcing the Code of Conduct, is allowed during this period. 99 | Violating these terms may lead to a permanent ban. 100 | 101 | ### 4. Permanent Ban 102 | 103 | **Community Impact**: Demonstrating a pattern of violation of community 104 | standards, including sustained inappropriate behaviour, harassment of an 105 | individual, or aggression toward or disparagement of classes of individuals. 106 | 107 | **Consequence**: A permanent ban from any sort of public interaction within 108 | the community. 109 | 110 | ## Attribution 111 | 112 | This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version 113 | [1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and 114 | [2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md), 115 | and was generated by [contributing.md](https://contributing.md/generator). 116 | -------------------------------------------------------------------------------- /tests/core/test_config.py: -------------------------------------------------------------------------------- 1 | """Tests for Golf MCP configuration management.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from golf.core.config import ( 7 | find_config_path, 8 | find_project_root, 9 | load_settings, 10 | ) 11 | 12 | 13 | class TestConfigDiscovery: 14 | """Test configuration file discovery.""" 15 | 16 | def test_finds_golf_json_in_current_dir(self, temp_dir: Path) -> None: 17 | """Test finding golf.json in the current directory.""" 18 | config_file = temp_dir / "golf.json" 19 | config_file.write_text('{"name": "TestProject"}') 20 | 21 | config_path = find_config_path(temp_dir) 22 | assert config_path == config_file 23 | 24 | def test_finds_golf_json_in_parent_dir(self, temp_dir: Path) -> None: 25 | """Test finding golf.json in a parent directory.""" 26 | config_file = temp_dir / "golf.json" 27 | config_file.write_text('{"name": "TestProject"}') 28 | 29 | subdir = temp_dir / "subdir" 30 | subdir.mkdir() 31 | 32 | config_path = find_config_path(subdir) 33 | assert config_path == config_file 34 | 35 | def test_returns_none_when_no_config(self, temp_dir: Path) -> None: 36 | """Test that None is returned when no config file exists.""" 37 | config_path = find_config_path(temp_dir) 38 | assert config_path is None 39 | 40 | 41 | class TestProjectRoot: 42 | """Test project root discovery.""" 43 | 44 | def test_finds_project_root(self, sample_project: Path) -> None: 45 | """Test finding project root from config file.""" 46 | root, config = find_project_root(sample_project) 47 | assert root == sample_project 48 | assert config == sample_project / "golf.json" 49 | 50 | def test_finds_project_root_from_subdir(self, sample_project: Path) -> None: 51 | """Test finding project root from a subdirectory.""" 52 | subdir = sample_project / "tools" / "nested" 53 | subdir.mkdir(parents=True) 54 | 55 | root, config = find_project_root(subdir) 56 | assert root == sample_project 57 | assert config == sample_project / "golf.json" 58 | 59 | 60 | class TestSettingsLoading: 61 | """Test settings loading and parsing.""" 62 | 63 | def test_loads_default_settings(self, temp_dir: Path) -> None: 64 | """Test loading settings with defaults when no config exists.""" 65 | settings = load_settings(temp_dir) 66 | assert settings.name == "GolfMCP Project" 67 | assert settings.host == "localhost" 68 | assert settings.port == 3000 69 | assert settings.transport == "streamable-http" 70 | 71 | def test_loads_settings_from_json(self, temp_dir: Path) -> None: 72 | """Test loading settings from golf.json.""" 73 | config = { 74 | "name": "MyProject", 75 | "description": "Test project", 76 | "host": "0.0.0.0", 77 | "port": 8080, 78 | "transport": "sse", 79 | } 80 | 81 | config_file = temp_dir / "golf.json" 82 | config_file.write_text(json.dumps(config)) 83 | 84 | settings = load_settings(temp_dir) 85 | assert settings.name == "MyProject" 86 | assert settings.description == "Test project" 87 | assert settings.host == "0.0.0.0" 88 | assert settings.port == 8080 89 | assert settings.transport == "sse" 90 | 91 | def test_env_file_override(self, temp_dir: Path) -> None: 92 | """Test that .env file values override defaults.""" 93 | # Create .env file 94 | env_file = temp_dir / ".env" 95 | env_file.write_text("GOLF_PORT=9000\nGOLF_HOST=localhost") 96 | 97 | settings = load_settings(temp_dir) 98 | assert settings.port == 9000 99 | assert settings.host == "localhost" 100 | 101 | def test_health_check_defaults(self, temp_dir: Path) -> None: 102 | """Test health check default configuration values.""" 103 | settings = load_settings(temp_dir) 104 | assert settings.health_check_enabled is False 105 | assert settings.health_check_path == "/health" 106 | assert settings.health_check_response == "OK" 107 | 108 | def test_health_check_configuration_from_json(self, temp_dir: Path) -> None: 109 | """Test loading health check configuration from golf.json.""" 110 | config = { 111 | "name": "HealthProject", 112 | "health_check_enabled": True, 113 | "health_check_path": "/status", 114 | "health_check_response": "Service is healthy", 115 | } 116 | 117 | config_file = temp_dir / "golf.json" 118 | config_file.write_text(json.dumps(config)) 119 | 120 | settings = load_settings(temp_dir) 121 | assert settings.health_check_enabled is True 122 | assert settings.health_check_path == "/status" 123 | assert settings.health_check_response == "Service is healthy" 124 | 125 | def test_health_check_partial_configuration(self, temp_dir: Path) -> None: 126 | """Test that partial health check configuration uses defaults for missing values.""" 127 | config = {"name": "PartialHealthProject", "health_check_enabled": True} 128 | 129 | config_file = temp_dir / "golf.json" 130 | config_file.write_text(json.dumps(config)) 131 | 132 | settings = load_settings(temp_dir) 133 | assert settings.health_check_enabled is True 134 | assert settings.health_check_path == "/health" # default 135 | assert settings.health_check_response == "OK" # default 136 | -------------------------------------------------------------------------------- /src/golf/utilities/elicitation.py: -------------------------------------------------------------------------------- 1 | """Elicitation utilities for Golf MCP tools. 2 | 3 | This module provides simplified elicitation functions that Golf tool authors 4 | can use without needing to manage FastMCP Context objects directly. 5 | """ 6 | 7 | from typing import Any, TypeVar, overload 8 | from collections.abc import Callable 9 | 10 | from .context import get_current_context 11 | 12 | T = TypeVar("T") 13 | 14 | # Apply telemetry instrumentation if available 15 | try: 16 | from golf.telemetry import instrument_elicitation 17 | 18 | _instrumentation_available = True 19 | except ImportError: 20 | _instrumentation_available = False 21 | 22 | def instrument_elicitation(func: Callable, elicitation_type: str = "elicit") -> Callable: 23 | """No-op instrumentation when telemetry is not available.""" 24 | return func 25 | 26 | 27 | @overload 28 | async def elicit( 29 | message: str, 30 | response_type: None = None, 31 | ) -> dict[str, Any]: 32 | """Elicit with no response type returns empty dict.""" 33 | ... 34 | 35 | 36 | @overload 37 | async def elicit( 38 | message: str, 39 | response_type: type[T], 40 | ) -> T: 41 | """Elicit with response type returns typed data.""" 42 | ... 43 | 44 | 45 | @overload 46 | async def elicit( 47 | message: str, 48 | response_type: list[str], 49 | ) -> str: 50 | """Elicit with list of options returns selected string.""" 51 | ... 52 | 53 | 54 | async def elicit( 55 | message: str, 56 | response_type: type[T] | list[str] | None = None, 57 | ) -> T | dict[str, Any] | str: 58 | """Request additional information from the user via MCP elicitation. 59 | 60 | This is a simplified wrapper around FastMCP's Context.elicit() method 61 | that automatically handles context retrieval and response processing. 62 | 63 | Args: 64 | message: Human-readable message explaining what information is needed 65 | response_type: The type of response expected: 66 | - None: Returns empty dict (for confirmation prompts) 67 | - type[T]: Returns validated instance of T (BaseModel, dataclass, etc.) 68 | - list[str]: Returns selected string from the options 69 | 70 | Returns: 71 | The user's response in the requested format 72 | 73 | Raises: 74 | RuntimeError: If called outside MCP context or user declines/cancels 75 | ValueError: If response validation fails 76 | 77 | Examples: 78 | ```python 79 | from golf.utilities import elicit 80 | from pydantic import BaseModel 81 | 82 | class UserInfo(BaseModel): 83 | name: str 84 | email: str 85 | 86 | async def collect_user_info(): 87 | # Structured elicitation 88 | info = await elicit("Please provide your details:", UserInfo) 89 | 90 | # Simple text elicitation 91 | reason = await elicit("Why do you need this?", str) 92 | 93 | # Multiple choice elicitation 94 | priority = await elicit("Select priority:", ["low", "medium", "high"]) 95 | 96 | # Confirmation elicitation 97 | await elicit("Proceed with the action?") 98 | 99 | return f"User {info.name} requested {reason} with {priority} priority" 100 | ``` 101 | """ 102 | try: 103 | # Get the current FastMCP context 104 | ctx = get_current_context() 105 | 106 | # Call the context's elicit method 107 | result = await ctx.elicit(message, response_type) 108 | 109 | # Handle the response based on the action 110 | if hasattr(result, "action"): 111 | if result.action == "accept": 112 | return result.data 113 | elif result.action == "decline": 114 | raise RuntimeError(f"User declined the elicitation request: {message}") 115 | elif result.action == "cancel": 116 | raise RuntimeError(f"User cancelled the elicitation request: {message}") 117 | else: 118 | raise RuntimeError(f"Unexpected elicitation response: {result.action}") 119 | else: 120 | # Direct response (shouldn't happen with current FastMCP) 121 | return result 122 | 123 | except Exception as e: 124 | if isinstance(e, RuntimeError): 125 | raise # Re-raise our custom errors 126 | raise RuntimeError(f"Elicitation failed: {str(e)}") from e 127 | 128 | 129 | async def elicit_confirmation(message: str) -> bool: 130 | """Request a simple yes/no confirmation from the user. 131 | 132 | This is a convenience function for common confirmation prompts. 133 | 134 | Args: 135 | message: The confirmation message to show the user 136 | 137 | Returns: 138 | True if user confirmed, False if declined 139 | 140 | Raises: 141 | RuntimeError: If user cancels or other error occurs 142 | 143 | Example: 144 | ```python 145 | from golf.utilities import elicit_confirmation 146 | 147 | async def delete_file(filename: str): 148 | confirmed = await elicit_confirmation( 149 | f"Are you sure you want to delete {filename}?" 150 | ) 151 | if confirmed: 152 | # Proceed with deletion 153 | return f"Deleted {filename}" 154 | else: 155 | return "Deletion cancelled" 156 | ``` 157 | """ 158 | try: 159 | # Use elicitation with boolean choice 160 | choice = await elicit(message, ["yes", "no"]) 161 | return choice.lower() == "yes" 162 | except RuntimeError as e: 163 | if "declined" in str(e): 164 | return False 165 | raise # Re-raise cancellation or other errors 166 | 167 | 168 | # Apply instrumentation to all elicitation functions 169 | elicit = instrument_elicitation(elicit, "elicit") 170 | elicit_confirmation = instrument_elicitation(elicit_confirmation, "confirmation") 171 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Golf is a Python framework for building MCP (Model Context Protocol) servers with zero boilerplate. It automatically discovers, parses, and compiles Python files containing tools, resources, and prompts into a runnable FastMCP server. 8 | 9 | ## Breaking Changes in Golf 0.2.x 10 | 11 | Golf 0.2.x introduces breaking changes to align with FastMCP 2.11.x: 12 | 13 | - **Authentication System**: Complete rewrite using FastMCP's built-in auth providers (JWT, OAuth, Static tokens) 14 | - **Legacy OAuth Removed**: Custom OAuth implementation replaced with standards-compliant FastMCP providers 15 | - **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 17 | - **Removed Files**: Legacy `oauth.py` and `provider.py` files removed from auth module 18 | - **Deprecated Functions**: `get_provider_token()` and OAuth-related helpers return None (legacy compatibility) 19 | 20 | ## Key Architecture 21 | 22 | - **Component Discovery**: Golf automatically scans `tools/`, `resources/`, and `prompts/` directories for Python files 23 | - **Code Generation**: The `ManifestBuilder` in `src/golf/core/builder.py` generates FastMCP server code from parsed components 24 | - **CLI Interface**: Entry point is `src/golf/cli/main.py` with commands: `init`, `build`, `run` 25 | - **Configuration**: Project settings managed via `golf.json` files, parsed by `src/golf/core/config.py` 26 | - **Authentication**: Modern JWT/OAuth auth using FastMCP 2.11+ providers in `src/golf/auth/` 27 | - **Telemetry**: Anonymous usage tracking with OpenTelemetry support in `src/golf/telemetry/` 28 | 29 | ## Common Development Commands 30 | 31 | ### Testing 32 | ```bash 33 | # Run all tests with coverage 34 | make test 35 | # OR directly: 36 | python -m pytest tests/ -v --cov=golf --cov-report=term-missing --cov-report=html 37 | 38 | # Run tests without coverage (faster) 39 | make test-fast 40 | # OR directly: 41 | python -m pytest tests/ -v 42 | ``` 43 | 44 | ### Code Quality 45 | ```bash 46 | # Run linting (ruff) 47 | python -m ruff check src/ 48 | 49 | # Format code (ruff format only) 50 | python -m ruff format src/ tests/ 51 | ``` 52 | 53 | ### Installation 54 | ```bash 55 | # Install in development mode with telemetry dependencies 56 | make install-dev 57 | 58 | # Test CLI installation 59 | golf --version 60 | ``` 61 | 62 | ## Project Structure 63 | 64 | - `src/golf/cli/` - CLI commands and entry points 65 | - `src/golf/core/` - Core framework logic (builder, parser, config) 66 | - `src/golf/auth/` - Authentication providers (OAuth, API keys) 67 | - `src/golf/telemetry/` - Usage tracking and OpenTelemetry instrumentation 68 | - `src/golf/metrics/` - Prometheus metrics collection 69 | - `src/golf/examples/` - Example projects (basic template) 70 | - `tests/` - Test suite with pytest 71 | 72 | ## Testing Framework 73 | 74 | Uses pytest with these key configurations: 75 | - Test discovery: `test_*.py` files in `tests/` directory 76 | - Async support: `asyncio_mode = "auto"` 77 | - Coverage reporting: HTML reports in `htmlcov/` 78 | - Markers: `slow` and `integration` for test categorization 79 | - Configuration in `pyproject.toml` under `[tool.pytest.ini_options]` 80 | 81 | ## Code Style 82 | 83 | - **Black** formatting (88 char line length) 84 | - **Ruff** linting with strict rules 85 | - **Mypy** type checking with strict settings 86 | - Configuration in `pyproject.toml` 87 | 88 | ## Git Commit Guidelines 89 | 90 | Follow these commit message patterns when making changes: 91 | 92 | - `fix[component]: description` - Bug fixes (e.g., `fix[parser]: handle edge case in import resolution`) 93 | - `feat[component]: description` - New features (e.g., `feat[builder]: add shared file support`) 94 | - `refactor[component]: description` - Code refactoring (e.g., `refactor[auth]: simplify provider creation`) 95 | - `test[component]: description` - Test additions/changes (e.g., `test[core]: add integration tests`) 96 | - `docs[component]: description` - Documentation updates (e.g., `docs[api]: update authentication guide`) 97 | - `style[component]: description` - Code formatting (e.g., `style[core]: format with ruff`) 98 | 99 | Examples from recent commits: 100 | - `fix[parser]: add shared file discovery for enhanced imports` 101 | - `fix[builder]: enhance import mapping for any shared file` 102 | - `fix[transformer]: improve import transformation patterns` 103 | 104 | ## Component System 105 | 106 | Golf projects have this structure: 107 | ``` 108 | project/ 109 | ├── golf.json # Configuration 110 | ├── tools/ # Tool implementations 111 | ├── resources/ # Resource implementations 112 | ├── prompts/ # Prompt templates 113 | └── auth.py # Optional authentication configuration 114 | ``` 115 | 116 | Component IDs are derived from file paths: `tools/payments/charge.py` becomes `charge_payments`. 117 | 118 | ## Authentication in Golf 0.2.x 119 | 120 | Golf 0.2.x uses FastMCP's built-in authentication providers: 121 | 122 | ### JWT Authentication (Production) 123 | ```python 124 | # In auth.py 125 | from golf.auth import configure_jwt_auth 126 | 127 | configure_jwt_auth( 128 | jwks_uri_env_var="JWKS_URI", # JWKS endpoint 129 | issuer_env_var="JWT_ISSUER", 130 | audience_env_var="JWT_AUDIENCE", 131 | required_scopes=["read:user"], 132 | ) 133 | ``` 134 | 135 | ### Development Authentication 136 | ```python 137 | # In auth.py 138 | from golf.auth import configure_dev_auth 139 | 140 | configure_dev_auth( 141 | tokens={ 142 | "dev-token-123": { 143 | "client_id": "dev-client", 144 | "scopes": ["read", "write"], 145 | } 146 | }, 147 | required_scopes=["read"], 148 | ) 149 | ``` 150 | 151 | ### API Key Authentication 152 | ```python 153 | # In auth.py 154 | from golf.auth import configure_api_key 155 | 156 | configure_api_key( 157 | header_name="Authorization", 158 | header_prefix="Bearer ", 159 | required=True, 160 | ) 161 | ``` -------------------------------------------------------------------------------- /tests/core/test_telemetry.py: -------------------------------------------------------------------------------- 1 | """Tests for Golf MCP telemetry functionality.""" 2 | 3 | from golf.core.telemetry import ( 4 | _sanitize_error_message, 5 | get_anonymous_id, 6 | is_telemetry_enabled, 7 | set_telemetry_enabled, 8 | track_event, 9 | ) 10 | 11 | 12 | class TestTelemetryConfiguration: 13 | """Test telemetry configuration and preferences.""" 14 | 15 | def test_telemetry_disabled_by_env(self, monkeypatch) -> None: 16 | """Test that telemetry respects environment variable.""" 17 | # Note: isolate_telemetry fixture already sets GOLF_TELEMETRY=0 18 | assert not is_telemetry_enabled() 19 | 20 | def test_telemetry_disabled_by_test_mode_env(self, monkeypatch) -> None: 21 | """Test that telemetry is disabled when GOLF_TEST_MODE is set.""" 22 | # Clear the telemetry env var set by fixture 23 | monkeypatch.delenv("GOLF_TELEMETRY", raising=False) 24 | # Set test mode 25 | monkeypatch.setenv("GOLF_TEST_MODE", "1") 26 | 27 | # Reset cached state 28 | from golf.core import telemetry 29 | 30 | telemetry._telemetry_enabled = None 31 | 32 | # Should be disabled due to test mode 33 | assert not is_telemetry_enabled() 34 | 35 | def test_set_telemetry_enabled(self, monkeypatch) -> None: 36 | """Test enabling/disabling telemetry programmatically.""" 37 | # Start with telemetry disabled by fixture 38 | assert not is_telemetry_enabled() 39 | 40 | # Enable telemetry (without persisting) 41 | set_telemetry_enabled(True, persist=False) 42 | assert is_telemetry_enabled() 43 | 44 | # Disable again 45 | set_telemetry_enabled(False, persist=False) 46 | assert not is_telemetry_enabled() 47 | 48 | def test_anonymous_id_generation(self) -> None: 49 | """Test that anonymous ID is generated correctly.""" 50 | id1 = get_anonymous_id() 51 | assert id1 is not None 52 | assert id1.startswith("golf-") 53 | assert len(id1) > 10 # Should have some reasonable length 54 | 55 | # Should return same ID on subsequent calls 56 | id2 = get_anonymous_id() 57 | assert id1 == id2 58 | 59 | def test_anonymous_id_format(self) -> None: 60 | """Test that anonymous ID follows expected format.""" 61 | anon_id = get_anonymous_id() 62 | # Format: golf-[hash]-[random] 63 | parts = anon_id.split("-") 64 | assert len(parts) == 3 65 | assert parts[0] == "golf" 66 | assert len(parts[1]) == 8 # Hash component 67 | assert len(parts[2]) == 8 # Random component 68 | 69 | 70 | class TestErrorSanitization: 71 | """Test error message sanitization.""" 72 | 73 | def test_sanitizes_file_paths(self) -> None: 74 | """Test that file paths are sanitized.""" 75 | # Unix paths 76 | msg = "Error in /Users/john/projects/myapp/secret.py" 77 | sanitized = _sanitize_error_message(msg) 78 | assert "/Users/john" not in sanitized 79 | assert "secret.py" in sanitized 80 | 81 | # Windows paths 82 | msg = "Error in C:\\Users\\john\\projects\\app.py" 83 | sanitized = _sanitize_error_message(msg) 84 | assert "C:\\Users\\john" not in sanitized 85 | assert "app.py" in sanitized 86 | 87 | def test_sanitizes_api_keys(self) -> None: 88 | """Test that API keys are sanitized.""" 89 | msg = "Invalid API key: sk_test_abcdef1234567890abcdef1234567890" 90 | sanitized = _sanitize_error_message(msg) 91 | assert "sk_test_abcdef1234567890abcdef1234567890" not in sanitized 92 | assert "[REDACTED]" in sanitized 93 | 94 | def test_sanitizes_email_addresses(self) -> None: 95 | """Test that email addresses are sanitized.""" 96 | msg = "User john.doe@example.com not found" 97 | sanitized = _sanitize_error_message(msg) 98 | assert "john.doe@example.com" not in sanitized 99 | assert "[EMAIL]" in sanitized 100 | 101 | def test_sanitizes_ip_addresses(self) -> None: 102 | """Test that IP addresses are sanitized.""" 103 | msg = "Connection failed to 192.168.1.100" 104 | sanitized = _sanitize_error_message(msg) 105 | assert "192.168.1.100" not in sanitized 106 | assert "[IP]" in sanitized 107 | 108 | def test_truncates_long_messages(self) -> None: 109 | """Test that long messages are truncated.""" 110 | # Use a message that won't trigger redaction patterns 111 | msg = "Error: " + "This is a very long error message. " * 20 112 | sanitized = _sanitize_error_message(msg) 113 | assert len(sanitized) <= 200 114 | # Only check for ellipsis if the message was actually truncated 115 | if len(msg) > 200: 116 | assert sanitized.endswith("...") 117 | 118 | 119 | class TestEventTracking: 120 | """Test event tracking functionality.""" 121 | 122 | def test_track_event_when_disabled(self) -> None: 123 | """Test that events are not tracked when telemetry is disabled.""" 124 | # Telemetry is disabled by fixture 125 | assert not is_telemetry_enabled() 126 | 127 | # This should not raise any errors 128 | track_event("test_event", {"key": "value"}) 129 | 130 | # No way to verify it wasn't sent without mocking 131 | # but at least it shouldn't crash 132 | 133 | def test_track_event_filters_properties(self) -> None: 134 | """Test that event properties are filtered.""" 135 | # Even though telemetry is disabled, we can test the logic 136 | # by enabling it temporarily 137 | set_telemetry_enabled(True, persist=False) 138 | 139 | # This should filter out unsafe properties 140 | track_event( 141 | "test_event", 142 | { 143 | "success": True, 144 | "environment": "test", 145 | "sensitive_data": "should_be_filtered", 146 | "user_email": "test@example.com", 147 | }, 148 | ) 149 | 150 | # Reset 151 | set_telemetry_enabled(False, persist=False) 152 | -------------------------------------------------------------------------------- /src/golf/auth/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for working with authentication in MCP context.""" 2 | 3 | from contextvars import ContextVar 4 | 5 | 6 | # Context variable to store the current request's API key 7 | _current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None) 8 | 9 | 10 | def extract_token_from_header(auth_header: str) -> str | None: 11 | """Extract bearer token from Authorization header. 12 | 13 | Args: 14 | auth_header: Authorization header value 15 | 16 | Returns: 17 | Bearer token or None if not present/valid 18 | """ 19 | if not auth_header: 20 | return None 21 | 22 | parts = auth_header.split() 23 | if len(parts) != 2 or parts[0].lower() != "bearer": 24 | return None 25 | 26 | return parts[1] 27 | 28 | 29 | def set_api_key(api_key: str | None) -> None: 30 | """Set the API key for the current request context. 31 | 32 | This is an internal function used by the middleware. 33 | 34 | Args: 35 | api_key: The API key to store in the context 36 | """ 37 | _current_api_key.set(api_key) 38 | 39 | 40 | def get_api_key() -> str | None: 41 | """Get the API key from the current request context. 42 | 43 | This function should be used in tools to retrieve the API key 44 | that was sent in the request headers. 45 | 46 | Returns: 47 | The API key if available, None otherwise 48 | 49 | Example: 50 | # In a tool file 51 | from golf.auth import get_api_key 52 | 53 | async def call_api(): 54 | api_key = get_api_key() 55 | if not api_key: 56 | return {"error": "No API key provided"} 57 | 58 | # Use the API key in your request 59 | headers = {"Authorization": f"Bearer {api_key}"} 60 | ... 61 | """ 62 | # Try to get directly from HTTP request if available (FastMCP pattern) 63 | try: 64 | # This follows the FastMCP pattern for accessing HTTP requests 65 | from fastmcp.server.dependencies import get_http_request 66 | 67 | request = get_http_request() 68 | 69 | if request and hasattr(request, "state") and hasattr(request.state, "api_key"): 70 | api_key = request.state.api_key 71 | return api_key 72 | 73 | # Get the API key configuration 74 | from golf.auth.api_key import get_api_key_config 75 | 76 | api_key_config = get_api_key_config() 77 | 78 | if api_key_config and request: 79 | # Extract API key from headers 80 | header_name = api_key_config.header_name 81 | header_prefix = api_key_config.header_prefix 82 | 83 | # Case-insensitive header lookup 84 | api_key = None 85 | for k, v in request.headers.items(): 86 | if k.lower() == header_name.lower(): 87 | api_key = v 88 | break 89 | 90 | # Strip prefix if configured 91 | if api_key and header_prefix and api_key.startswith(header_prefix): 92 | api_key = api_key[len(header_prefix) :] 93 | 94 | if api_key: 95 | return api_key 96 | except (ImportError, RuntimeError): 97 | # FastMCP not available or not in HTTP context 98 | pass 99 | except Exception: 100 | pass 101 | 102 | # Final fallback: environment variable (for development/testing) 103 | import os 104 | 105 | env_api_key = os.environ.get("API_KEY") 106 | if env_api_key: 107 | return env_api_key 108 | 109 | return None 110 | 111 | 112 | def get_auth_token() -> str | None: 113 | """Get the authorization token from the current request context. 114 | 115 | This function should be used in tools to retrieve the authorization token 116 | (typically a JWT or OAuth token) that was sent in the request headers. 117 | 118 | Unlike get_api_key(), this function extracts the raw token from the Authorization 119 | header without stripping any prefix, making it suitable for passing through 120 | to upstream APIs that expect the full Authorization header value. 121 | 122 | Returns: 123 | The authorization token if available, None otherwise 124 | 125 | Example: 126 | # In a tool file 127 | from golf.auth import get_auth_token 128 | 129 | async def call_upstream_api(): 130 | auth_token = get_auth_token() 131 | if not auth_token: 132 | return {"error": "No authorization token provided"} 133 | 134 | # Use the full token in upstream request 135 | headers = {"Authorization": f"Bearer {auth_token}"} 136 | async with httpx.AsyncClient() as client: 137 | response = await client.get("https://api.example.com/data", headers=headers) 138 | ... 139 | """ 140 | # Try to get directly from HTTP request if available (FastMCP pattern) 141 | try: 142 | # This follows the FastMCP pattern for accessing HTTP requests 143 | from fastmcp.server.dependencies import get_http_request 144 | 145 | request = get_http_request() 146 | 147 | if request and hasattr(request, "state") and hasattr(request.state, "auth_token"): 148 | return request.state.auth_token 149 | 150 | if request: 151 | # Extract authorization token from Authorization header 152 | auth_header = None 153 | for k, v in request.headers.items(): 154 | if k.lower() == "authorization": 155 | auth_header = v 156 | break 157 | 158 | if auth_header: 159 | # Extract the token part (everything after "Bearer ") 160 | token = extract_token_from_header(auth_header) 161 | if token: 162 | return token 163 | 164 | # If not Bearer format, return the whole header value minus "Bearer " prefix if present 165 | if auth_header.lower().startswith("bearer "): 166 | return auth_header[7:] # Remove "Bearer " prefix 167 | return auth_header 168 | 169 | except (ImportError, RuntimeError): 170 | # FastMCP not available or not in HTTP context 171 | pass 172 | except Exception: 173 | pass 174 | 175 | return None 176 | -------------------------------------------------------------------------------- /src/golf/cli/branding.py: -------------------------------------------------------------------------------- 1 | """Golf CLI branding and visual utilities.""" 2 | 3 | from rich.console import Console 4 | from rich.panel import Panel 5 | from rich.text import Text 6 | from rich.align import Align 7 | 8 | # Golf brand colors (official brand colors) 9 | GOLF_BLUE = "#2969FD" # Primary blue from brand: rgb(41, 105, 253) 10 | GOLF_ORANGE = "#F97728" # Secondary orange from brand: rgb(249, 119, 40) 11 | GOLF_GREEN = "#10B981" # Success green 12 | GOLF_WHITE = "#FFFFFF" 13 | 14 | # Simple GolfMCP text logo 15 | GOLF_LOGO = """ 16 | ██████╗ ██████╗ ██╗ ███████╗███╗ ███╗ ██████╗██████╗ 17 | ██╔════╝ ██╔═══██╗██║ ██╔════╝████╗ ████║██╔════╝██╔══██╗ 18 | ██║ ███╗██║ ██║██║ █████╗ ██╔████╔██║██║ ██████╔╝ 19 | ██║ ██║██║ ██║██║ ██╔══╝ ██║╚██╔╝██║██║ ██╔═══╝ 20 | ╚██████╔╝╚██████╔╝███████╗██║ ██║ ╚═╝ ██║╚██████╗██║ 21 | ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ 22 | """ 23 | 24 | # Simplified version for smaller spaces 25 | GOLF_LOGO_SMALL = "Golf" 26 | 27 | # Status icons with consistent styling 28 | STATUS_ICONS = { 29 | "success": "✓", 30 | "error": "✗", 31 | "warning": "⚠", 32 | "info": "ℹ", 33 | "building": "🔨", 34 | "generating": "⚙️", 35 | "packaging": "📦", 36 | "server": "🚀", 37 | "loading": "⭕", 38 | } 39 | 40 | 41 | def create_welcome_banner(version: str, console: Console) -> None: 42 | """Create the main Golf welcome banner.""" 43 | # Create the logo with Golf in blue and MCP in orange 44 | logo_lines = GOLF_LOGO.strip().split("\n") 45 | logo_content = Text() 46 | 47 | for line in logo_lines: 48 | if line.strip(): # Only process non-empty lines 49 | # Find where "MCP" starts (roughly at position 32 in the ASCII art) 50 | golf_part = line[:32] # First part is "Golf" 51 | mcp_part = line[32:] # Last part is "MCP" 52 | 53 | logo_content.append(golf_part, style=f"bold {GOLF_BLUE}") 54 | logo_content.append(mcp_part, style=f"bold {GOLF_ORANGE}") 55 | logo_content.append("\n") 56 | 57 | # Create version line 58 | version_text = Text() 59 | version_text.append("🚀 ", style=f"bold {GOLF_ORANGE}") 60 | version_text.append(f"Golf v{version}", style=f"bold {GOLF_BLUE}") 61 | version_text.append(" 🚀", style=f"bold {GOLF_ORANGE}") 62 | 63 | # Create tagline 64 | tagline_text = Text("✨ Easiest way to build production-ready MCP servers ✨", style="bold white") 65 | 66 | # Create the full content using a renderable group approach 67 | from rich.console import Group 68 | 69 | content_group = Group( 70 | Align.center(logo_content), 71 | "", # Empty line for spacing 72 | Align.center(version_text), 73 | Align.center(tagline_text), 74 | ) 75 | 76 | panel = Panel( 77 | content_group, 78 | border_style=GOLF_BLUE, 79 | padding=(1, 2), 80 | title="[bold]🏌️ Welcome to Golf 🏌️[/bold]", 81 | title_align="center", 82 | ) 83 | 84 | console.print(panel) 85 | 86 | 87 | def create_command_header(title: str, subtitle: str = "", console: Console | None = None) -> None: 88 | """Create a styled command header.""" 89 | if console is None: 90 | console = Console() 91 | 92 | header = Text() 93 | header.append("🏌️ ", style=f"bold {GOLF_ORANGE}") 94 | header.append(title, style=f"bold {GOLF_BLUE}") 95 | 96 | if subtitle: 97 | header.append(f" → {subtitle}", style=f"bold {GOLF_ORANGE}") 98 | 99 | # Create a stylish panel for the header 100 | panel = Panel( 101 | Align.center(header), 102 | border_style=GOLF_BLUE, 103 | padding=(0, 2), 104 | ) 105 | 106 | console.print(panel) 107 | 108 | 109 | def create_success_message(message: str, console: Console | None = None) -> None: 110 | """Create a styled success message.""" 111 | if console is None: 112 | console = Console() 113 | 114 | success_content = Text() 115 | success_content.append("🎉 ", style=f"bold {GOLF_ORANGE}") 116 | success_content.append(f"{STATUS_ICONS['success']} {message}", style=f"bold {GOLF_GREEN}") 117 | success_content.append(" 🎉", style=f"bold {GOLF_ORANGE}") 118 | 119 | success_panel = Panel( 120 | Align.center(success_content), 121 | border_style=GOLF_GREEN, 122 | padding=(0, 2), 123 | title="[bold green]SUCCESS[/bold green]", 124 | title_align="center", 125 | ) 126 | console.print(success_panel) 127 | 128 | 129 | def create_info_panel(title: str, content: str, console: Console | None = None) -> None: 130 | """Create a styled info panel.""" 131 | if console is None: 132 | console = Console() 133 | 134 | # Add some visual flair to the content 135 | styled_content = Text() 136 | for line in content.split("\n"): 137 | if line.strip(): 138 | styled_content.append("▶ ", style=f"bold {GOLF_ORANGE}") 139 | styled_content.append(line, style="bold white") 140 | styled_content.append("\n") 141 | 142 | panel = Panel( 143 | styled_content, 144 | title=f"[bold {GOLF_BLUE}]🔧 {title} 🔧[/bold {GOLF_BLUE}]", 145 | border_style=GOLF_BLUE, 146 | padding=(1, 2), 147 | ) 148 | console.print(panel) 149 | 150 | 151 | def get_status_text(status: str, message: str, style: str = "") -> Text: 152 | """Get formatted status text with icon.""" 153 | icon = STATUS_ICONS.get(status, "•") 154 | text = Text() 155 | 156 | if status == "success": 157 | text.append("🎉 ", style=f"bold {GOLF_ORANGE}") 158 | text.append(f"{icon} {message}", style=f"bold {GOLF_GREEN}") 159 | elif status == "error": 160 | text.append("💥 ", style=f"bold {GOLF_ORANGE}") 161 | text.append(f"{icon} {message}", style="bold red") 162 | elif status == "warning": 163 | text.append("⚡ ", style=f"bold {GOLF_ORANGE}") 164 | text.append(f"{icon} {message}", style=f"bold {GOLF_ORANGE}") 165 | elif status in ["building", "generating", "packaging"]: 166 | text.append("🔥 ", style=f"bold {GOLF_ORANGE}") 167 | text.append(f"{icon} {message}", style=f"bold {GOLF_BLUE}") 168 | else: 169 | text.append("💡 ", style=f"bold {GOLF_ORANGE}") 170 | text.append(f"{icon} {message}", style=f"bold {GOLF_BLUE}") 171 | 172 | return text 173 | 174 | 175 | def create_build_header(project_name: str, environment: str, console: Console) -> None: 176 | """Create a styled build process header.""" 177 | title = Text() 178 | title.append("🔨 Building ", style=f"bold {GOLF_ORANGE}") 179 | title.append(project_name, style=f"bold {GOLF_BLUE}") 180 | title.append(f" ({environment} environment)", style=f"bold {GOLF_GREEN}") 181 | 182 | # Create a flashy build panel 183 | panel = Panel( 184 | Align.center(title), 185 | border_style=GOLF_ORANGE, 186 | padding=(0, 2), 187 | title="[bold]🚧 BUILD IN PROGRESS 🚧[/bold]", 188 | title_align="center", 189 | ) 190 | 191 | console.print(panel) 192 | -------------------------------------------------------------------------------- /src/golf/core/config.py: -------------------------------------------------------------------------------- 1 | """Configuration management for GolfMCP.""" 2 | 3 | from pathlib import Path 4 | from typing import Any 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(..., description="Authentication provider (e.g., 'jwks', 'google', 'github')") 17 | scopes: list[str] = Field(default_factory=list, description="Required OAuth scopes") 18 | client_id_env: str | None = Field(None, description="Environment variable name for client ID") 19 | client_secret_env: str | None = Field(None, description="Environment variable name for client secret") 20 | redirect_uri: str | None = Field(None, description="OAuth redirect URI (defaults to localhost callback)") 21 | 22 | @field_validator("provider") 23 | @classmethod 24 | def validate_provider(cls, value: str) -> str: 25 | """Validate the provider value.""" 26 | valid_providers = {"jwks", "google", "github", "custom"} 27 | if value not in valid_providers and not value.startswith("custom:"): 28 | raise ValueError(f"Invalid provider '{value}'. Must be one of {valid_providers} or start with 'custom:'") 29 | return value 30 | 31 | 32 | class DeployConfig(BaseModel): 33 | """Deployment configuration.""" 34 | 35 | default: str = Field("vercel", description="Default deployment target") 36 | options: dict[str, Any] = Field(default_factory=dict, description="Target-specific options") 37 | 38 | 39 | class Settings(BaseSettings): 40 | """GolfMCP application settings.""" 41 | 42 | model_config = SettingsConfigDict( 43 | env_prefix="GOLF_", 44 | env_file=".env", 45 | env_file_encoding="utf-8", 46 | extra="ignore", 47 | ) 48 | 49 | # Project metadata 50 | name: str = Field("GolfMCP Project", description="FastMCP instance name") 51 | description: str | None = Field(None, description="Project description") 52 | 53 | # Build settings 54 | output_dir: str = Field("dist", description="Build artifact folder") 55 | 56 | # Server settings 57 | host: str = Field("localhost", description="Server host") 58 | port: int = Field(3000, description="Server port") 59 | transport: str = Field( 60 | "streamable-http", 61 | description="Transport protocol (streamable-http, sse, stdio)", 62 | ) 63 | 64 | # Auth settings 65 | auth: str | AuthConfig | None = Field(None, description="Authentication configuration or URI") 66 | 67 | # Deploy settings 68 | deploy: DeployConfig = Field(default_factory=DeployConfig, description="Deployment configuration") 69 | 70 | # Feature flags 71 | telemetry: bool = Field(True, description="Enable anonymous telemetry") 72 | 73 | # Project paths 74 | tools_dir: str = Field("tools", description="Directory containing tools") 75 | resources_dir: str = Field("resources", description="Directory containing resources") 76 | prompts_dir: str = Field("prompts", description="Directory containing prompts") 77 | 78 | # OpenTelemetry config 79 | opentelemetry_enabled: bool = Field(False, description="Enable OpenTelemetry tracing") 80 | opentelemetry_default_exporter: str = Field("console", description="Default OpenTelemetry exporter type") 81 | detailed_tracing: bool = Field( 82 | False, description="Enable detailed tracing with input/output capture (may contain sensitive data)" 83 | ) 84 | 85 | # Health check configuration 86 | health_check_enabled: bool = Field(False, description="Enable health check endpoint (deprecated - use health.py)") 87 | health_check_path: str = Field("/health", description="Health check endpoint path") 88 | health_check_response: str = Field("OK", description="Health check response text (deprecated - use health.py)") 89 | 90 | # HTTP session behaviour 91 | stateless_http: bool = Field( 92 | False, 93 | description="Make Streamable-HTTP transport stateless (new session per request)", 94 | ) 95 | 96 | # Metrics configuration 97 | metrics_enabled: bool = Field(False, description="Enable Prometheus metrics endpoint") 98 | metrics_path: str = Field("/metrics", description="Metrics endpoint path") 99 | 100 | 101 | def find_config_path(start_path: Path | None = None) -> Path | None: 102 | """Find the golf config file by searching upwards from the given path. 103 | 104 | Args: 105 | start_path: Path to start searching from (defaults to current directory) 106 | 107 | Returns: 108 | Path to the config file if found, None otherwise 109 | """ 110 | if start_path is None: 111 | start_path = Path.cwd() 112 | 113 | current = start_path.absolute() 114 | 115 | # Don't search above the home directory 116 | home = Path.home().absolute() 117 | 118 | while current != current.parent and current != home: 119 | # Check for JSON config first (preferred) 120 | json_config = current / "golf.json" 121 | if json_config.exists(): 122 | return json_config 123 | 124 | # Fall back to TOML config 125 | toml_config = current / "golf.toml" 126 | if toml_config.exists(): 127 | return toml_config 128 | 129 | current = current.parent 130 | 131 | return None 132 | 133 | 134 | def find_project_root( 135 | start_path: Path | None = None, 136 | ) -> tuple[Path | None, Path | None]: 137 | """Find a GolfMCP project root by searching for a config file. 138 | 139 | This is the central project discovery function that should be used by all commands. 140 | 141 | Args: 142 | start_path: Path to start searching from (defaults to current directory) 143 | 144 | Returns: 145 | Tuple of (project_root, config_path) if a project is found, or 146 | (None, None) if not 147 | """ 148 | config_path = find_config_path(start_path) 149 | if config_path: 150 | return config_path.parent, config_path 151 | return None, None 152 | 153 | 154 | def load_settings(project_path: str | Path) -> Settings: 155 | """Load settings from a project directory. 156 | 157 | Args: 158 | project_path: Path to the project directory 159 | 160 | Returns: 161 | Settings object with values loaded from config files 162 | """ 163 | # Convert to Path if needed 164 | if isinstance(project_path, str): 165 | project_path = Path(project_path) 166 | 167 | # Create default settings 168 | settings = Settings() 169 | 170 | # Check for .env file 171 | env_file = project_path / ".env" 172 | if env_file.exists(): 173 | settings = Settings(_env_file=env_file) 174 | 175 | # Try to load JSON config file first 176 | json_config_path = project_path / "golf.json" 177 | if json_config_path.exists(): 178 | return _load_json_settings(json_config_path, settings) 179 | 180 | return settings 181 | 182 | 183 | def _load_json_settings(path: Path, settings: Settings) -> Settings: 184 | """Load settings from a JSON file.""" 185 | try: 186 | import json 187 | 188 | with open(path) 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 | -------------------------------------------------------------------------------- /src/golf/utilities/sampling.py: -------------------------------------------------------------------------------- 1 | """Sampling utilities for Golf MCP tools. 2 | 3 | This module provides simplified LLM sampling functions that Golf tool authors 4 | can use without needing to manage FastMCP Context objects directly. 5 | """ 6 | 7 | from typing import Any 8 | from collections.abc import Callable 9 | 10 | from .context import get_current_context 11 | 12 | # Apply telemetry instrumentation if available 13 | try: 14 | from golf.telemetry import instrument_sampling 15 | 16 | _instrumentation_available = True 17 | except ImportError: 18 | _instrumentation_available = False 19 | 20 | def instrument_sampling(func: Callable, sampling_type: str = "sample") -> Callable: 21 | """No-op instrumentation when telemetry is not available.""" 22 | return func 23 | 24 | 25 | async def sample( 26 | messages: str | list[str], 27 | system_prompt: str | None = None, 28 | temperature: float | None = None, 29 | max_tokens: int | None = None, 30 | model_preferences: str | list[str] | None = None, 31 | ) -> str: 32 | """Request an LLM completion from the MCP client. 33 | 34 | This is a simplified wrapper around FastMCP's Context.sample() method 35 | that automatically handles context retrieval and response processing. 36 | 37 | Args: 38 | messages: The message(s) to send to the LLM: 39 | - str: Single user message 40 | - list[str]: Multiple user messages 41 | system_prompt: Optional system prompt to guide the LLM 42 | temperature: Optional temperature for sampling (0.0 to 1.0) 43 | max_tokens: Optional maximum tokens to generate (default: 512) 44 | model_preferences: Optional model preferences: 45 | - str: Single model name hint 46 | - list[str]: Multiple model name hints in preference order 47 | 48 | Returns: 49 | The LLM's response as a string 50 | 51 | Raises: 52 | RuntimeError: If called outside MCP context or sampling fails 53 | ValueError: If parameters are invalid 54 | 55 | Examples: 56 | ```python 57 | from golf.utilities import sample 58 | 59 | async def analyze_data(data: str): 60 | # Simple completion 61 | analysis = await sample(f"Analyze this data: {data}") 62 | 63 | # With system prompt and temperature 64 | creative_response = await sample( 65 | "Write a creative story about this data", 66 | system_prompt="You are a creative writer", 67 | temperature=0.8, 68 | max_tokens=1000 69 | ) 70 | 71 | # With model preferences 72 | technical_analysis = await sample( 73 | f"Provide technical analysis: {data}", 74 | model_preferences=["gpt-4", "claude-3-sonnet"] 75 | ) 76 | 77 | return { 78 | "analysis": analysis, 79 | "creative": creative_response, 80 | "technical": technical_analysis 81 | } 82 | ``` 83 | """ 84 | try: 85 | # Get the current FastMCP context 86 | ctx = get_current_context() 87 | 88 | # Call the context's sample method 89 | result = await ctx.sample( 90 | messages=messages, 91 | system_prompt=system_prompt, 92 | temperature=temperature, 93 | max_tokens=max_tokens, 94 | model_preferences=model_preferences, 95 | ) 96 | 97 | # Extract text content from the ContentBlock response 98 | if hasattr(result, "text"): 99 | return result.text 100 | elif hasattr(result, "content"): 101 | # Handle different content block types 102 | if isinstance(result.content, str): 103 | return result.content 104 | elif hasattr(result.content, "text"): 105 | return result.content.text 106 | else: 107 | return str(result.content) 108 | else: 109 | return str(result) 110 | 111 | except Exception as e: 112 | raise RuntimeError(f"LLM sampling failed: {str(e)}") from e 113 | 114 | 115 | async def sample_structured( 116 | messages: str | list[str], 117 | format_instructions: str, 118 | system_prompt: str | None = None, 119 | temperature: float = 0.1, 120 | max_tokens: int | None = None, 121 | ) -> str: 122 | """Request a structured LLM completion with specific formatting. 123 | 124 | This is a convenience function for requesting structured responses 125 | like JSON, XML, or other formatted output. 126 | 127 | Args: 128 | messages: The message(s) to send to the LLM 129 | format_instructions: Instructions for the desired output format 130 | system_prompt: Optional system prompt 131 | temperature: Temperature for sampling (default: 0.1 for consistency) 132 | max_tokens: Optional maximum tokens to generate 133 | 134 | Returns: 135 | The structured LLM response as a string 136 | 137 | Example: 138 | ```python 139 | from golf.utilities import sample_structured 140 | 141 | async def extract_entities(text: str): 142 | entities = await sample_structured( 143 | f"Extract entities from: {text}", 144 | format_instructions="Return as JSON with keys: persons, " 145 | "organizations, locations", 146 | system_prompt="You are an expert at named entity recognition" 147 | ) 148 | return entities 149 | ``` 150 | """ 151 | # Combine the format instructions with the messages 152 | if isinstance(messages, str): 153 | formatted_message = f"{messages}\n\n{format_instructions}" 154 | else: 155 | formatted_message = messages + [format_instructions] 156 | 157 | return await sample( 158 | messages=formatted_message, 159 | system_prompt=system_prompt, 160 | temperature=temperature, 161 | max_tokens=max_tokens, 162 | ) 163 | 164 | 165 | async def sample_with_context( 166 | messages: str | list[str], 167 | context_data: dict[str, Any], 168 | system_prompt: str | None = None, 169 | **kwargs: Any, 170 | ) -> str: 171 | """Request an LLM completion with additional context data. 172 | 173 | This convenience function formats context data and includes it 174 | in the sampling request. 175 | 176 | Args: 177 | messages: The message(s) to send to the LLM 178 | context_data: Dictionary of context data to include 179 | system_prompt: Optional system prompt 180 | **kwargs: Additional arguments passed to sample() 181 | 182 | Returns: 183 | The LLM response as a string 184 | 185 | Example: 186 | ```python 187 | from golf.utilities import sample_with_context 188 | 189 | async def generate_report(topic: str, user_data: dict): 190 | report = await sample_with_context( 191 | f"Generate a report about {topic}", 192 | context_data={ 193 | "user_preferences": user_data, 194 | "timestamp": "2024-01-01", 195 | "format": "markdown" 196 | }, 197 | system_prompt="You are a professional report writer" 198 | ) 199 | return report 200 | ``` 201 | """ 202 | # Format context data as a readable string 203 | context_str = "\n".join([f"{k}: {v}" for k, v in context_data.items()]) 204 | 205 | # Add context to the message 206 | if isinstance(messages, str): 207 | contextual_message = f"{messages}\n\nContext:\n{context_str}" 208 | else: 209 | contextual_message = messages + [f"Context:\n{context_str}"] 210 | 211 | return await sample( 212 | messages=contextual_message, 213 | system_prompt=system_prompt, 214 | **kwargs, 215 | ) 216 | 217 | 218 | # Apply instrumentation to all sampling functions 219 | sample = instrument_sampling(sample, "sample") 220 | sample_structured = instrument_sampling(sample_structured, "structured") 221 | sample_with_context = instrument_sampling(sample_with_context, "context") 222 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Golf Banner 3 | 4 |
5 | 6 |

7 |
8 | ⛳ Golf 9 |
10 |

11 | 12 |

13 | Easiest framework for building MCP servers 14 |

15 | 16 |
17 | 18 |

19 | License 20 | PRs 21 | Support 22 |

23 | 24 |

25 | 📚 Documentation 26 |

27 |
28 | 29 | ## Overview 30 | 31 | Golf is a **framework** designed to streamline the creation of MCP server applications. It allows developers to define server's capabilities—*tools*, *prompts*, and *resources*—as simple Python files within a conventional directory structure. Golf then automatically discovers, parses, and compiles these components into a runnable MCP server, minimizing boilerplate and accelerating development. 32 | 33 | With Golf v0.2.0, you get **enterprise-grade authentication** (JWT, OAuth Server, API key, development tokens), **built-in utilities** for LLM interactions, and **automatic telemetry** integration. Focus on implementing your agent's logic while Golf handles authentication, monitoring, and server infrastructure. 34 | 35 | ## Quick Start 36 | 37 | Get your Golf project up and running in a few simple steps: 38 | 39 | ### 1. Install Golf 40 | 41 | Ensure you have Python (3.10+ recommended) installed. Then, install Golf using pip: 42 | 43 | ```bash 44 | pip install golf-mcp 45 | ``` 46 | 47 | ### 2. Initialize Your Project 48 | 49 | Use the Golf CLI to scaffold a new project: 50 | 51 | ```bash 52 | golf init your-project-name 53 | ``` 54 | This command creates a new directory (`your-project-name`) with a basic project structure, including example tools, resources, and a `golf.json` configuration file. 55 | 56 | ### 3. Run the Development Server 57 | 58 | Navigate into your new project directory and start the development server: 59 | 60 | ```bash 61 | cd your-project-name 62 | golf build dev 63 | golf run 64 | ``` 65 | This will start the MCP server, typically on `http://localhost:3000` (configurable in `golf.json`). 66 | 67 | That's it! Your Golf server is running and ready for integration. 68 | 69 | ## Basic Project Structure 70 | 71 | A Golf project initialized with `golf init` will have a structure similar to this: 72 | 73 | ``` 74 | / 75 | │ 76 | ├─ golf.json # Main project configuration 77 | │ 78 | ├─ tools/ # Directory for tool implementations 79 | │ └─ hello.py # Example tool 80 | │ 81 | ├─ resources/ # Directory for resource implementations 82 | │ └─ info.py # Example resource 83 | │ 84 | ├─ prompts/ # Directory for prompt templates 85 | │ └─ welcome.py # Example prompt 86 | │ 87 | ├─ .env # Environment variables (e.g., API keys, server port) 88 | └─ auth.py # Authentication configuration (JWT, OAuth Server, API key, dev tokens) 89 | ``` 90 | 91 | - **`golf.json`**: Configures server name, port, transport, telemetry, and other build settings. 92 | - **`auth.py`**: Dedicated authentication configuration file (new in v0.2.0, breaking change from v0.1.x authentication API) for JWT, OAuth Server, API key, or development authentication. 93 | - **`tools/`**, **`resources/`**, **`prompts/`**: Contain your Python files, each defining a single component. These directories can also contain nested subdirectories to further organize your components (e.g., `tools/payments/charge.py`). The module docstring of each file serves as the component's description. 94 | - Component IDs are automatically derived from their file path. For example, `tools/hello.py` becomes `hello`, and a nested file like `tools/payments/submit.py` would become `submit_payments` (filename, followed by reversed parent directories under the main category, joined by underscores). 95 | 96 | ## Example: Defining a Tool 97 | 98 | Creating a new tool is as simple as adding a Python file to the `tools/` directory. The example `tools/hello.py` in the boilerplate looks like this: 99 | 100 | ```python 101 | # tools/hello.py 102 | """Hello World tool {{project_name}}.""" 103 | 104 | from typing import Annotated 105 | from pydantic import BaseModel, Field 106 | 107 | class Output(BaseModel): 108 | """Response from the hello tool.""" 109 | message: str 110 | 111 | async def hello( 112 | name: Annotated[str, Field(description="The name of the person to greet")] = "World", 113 | greeting: Annotated[str, Field(description="The greeting phrase to use")] = "Hello" 114 | ) -> Output: 115 | """Say hello to the given name. 116 | 117 | This is a simple example tool that demonstrates the basic structure 118 | of a tool implementation in Golf. 119 | """ 120 | print(f"{greeting} {name}...") 121 | return Output(message=f"{greeting}, {name}!") 122 | 123 | # Designate the entry point function 124 | export = hello 125 | ``` 126 | Golf will automatically discover this file. The module docstring `"""Hello World tool {{project_name}}."""` is used as the tool's description. It infers parameters from the `hello` function's signature and uses the `Output` Pydantic model for the output schema. The tool will be registered with the ID `hello`. 127 | 128 | ## Authentication & Features 129 | 130 | Golf includes enterprise-grade authentication, built-in utilities, and automatic telemetry: 131 | 132 | ```python 133 | # auth.py - Configure authentication 134 | from golf.auth import configure_auth, JWTAuthConfig, StaticTokenConfig, OAuthServerConfig 135 | 136 | # JWT authentication (production) 137 | configure_auth(JWTAuthConfig( 138 | jwks_uri_env_var="JWKS_URI", 139 | issuer_env_var="JWT_ISSUER", 140 | audience_env_var="JWT_AUDIENCE", 141 | required_scopes=["read", "write"] 142 | )) 143 | 144 | # OAuth Server mode (Golf acts as OAuth 2.0 server) 145 | # configure_auth(OAuthServerConfig( 146 | # base_url="https://your-golf-server.com", 147 | # valid_scopes=["read", "write", "admin"] 148 | # )) 149 | 150 | # Static tokens (development only) 151 | # configure_auth(StaticTokenConfig( 152 | # tokens={"dev-token": {"client_id": "dev", "scopes": ["read"]}} 153 | # )) 154 | 155 | # Built-in utilities available in all tools 156 | from golf.utils import elicit, sample, get_context 157 | ``` 158 | 159 | ```bash 160 | # Enable OpenTelemetry tracing 161 | export OTEL_TRACES_EXPORTER="otlp_http" 162 | export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces" 163 | golf run # ✅ Telemetry enabled 164 | ``` 165 | 166 | **[📚 Complete Documentation →](https://docs.golf.dev)** 167 | 168 | ## Configuration 169 | 170 | Basic configuration in `golf.json`: 171 | 172 | ```json 173 | { 174 | "name": "My Golf Server", 175 | "host": "localhost", 176 | "port": 3000, 177 | "transport": "sse", 178 | "opentelemetry_enabled": false, 179 | "detailed_tracing": false 180 | } 181 | ``` 182 | 183 | - **`transport`**: Choose `"sse"`, `"streamable-http"`, or `"stdio"` 184 | - **`opentelemetry_enabled`**: Enable OpenTelemetry tracing 185 | - **`detailed_tracing`**: Capture input/output (use carefully with sensitive data) 186 | 187 | 188 | ## Privacy & Telemetry 189 | 190 | Golf collects **anonymous** usage data on the CLI to help us understand how the framework is being used and improve it over time. The data collected includes: 191 | 192 | - Commands run (init, build, run) 193 | - Success/failure status (no error details) 194 | - Golf version, Python version (major.minor only), and OS type 195 | - Template name (for init command only) 196 | - Build environment (dev/prod for build commands only) 197 | 198 | **No personal information, project names, code content, or error messages are ever collected.** 199 | 200 | ### Opting Out 201 | 202 | You can disable telemetry in several ways: 203 | 204 | 1. **Using the telemetry command** (recommended): 205 | ```bash 206 | golf telemetry disable 207 | ``` 208 | This saves your preference permanently. To re-enable: 209 | ```bash 210 | golf telemetry enable 211 | ``` 212 | 213 | 2. **During any command**: Add `--no-telemetry` to save your preference: 214 | ```bash 215 | golf init my-project --no-telemetry 216 | ``` 217 | 218 | Your telemetry preference is stored in `~/.golf/telemetry.json` and persists across all Golf commands. 219 | 220 |
221 | Made with ❤️ in Warsaw, Poland and SF 222 |
-------------------------------------------------------------------------------- /src/golf/core/builder_auth.py: -------------------------------------------------------------------------------- 1 | """Authentication integration for the Golf MCP build process. 2 | 3 | This module adds support for injecting authentication configuration 4 | into the generated FastMCP application during the build process using 5 | FastMCP 2.11+ built-in auth providers. 6 | """ 7 | 8 | from golf.auth import get_auth_config, is_auth_configured 9 | from golf.auth.api_key import get_api_key_config 10 | from golf.auth.providers import AuthConfig 11 | 12 | 13 | def generate_auth_code( 14 | server_name: str, 15 | host: str = "localhost", 16 | port: int = 3000, 17 | https: bool = False, 18 | opentelemetry_enabled: bool = False, 19 | transport: str = "streamable-http", 20 | ) -> dict: 21 | """Generate authentication components for the FastMCP app using modern 22 | auth providers. 23 | 24 | Returns a dictionary with: 25 | - imports: List of import statements 26 | - setup_code: Auth setup code (provider configuration, etc.) 27 | - fastmcp_args: Dict of arguments to add to FastMCP constructor 28 | - has_auth: Whether auth is configured 29 | """ 30 | # Check for API key configuration first 31 | api_key_config = get_api_key_config() 32 | if api_key_config: 33 | return generate_api_key_auth_components(server_name, opentelemetry_enabled, transport) 34 | 35 | # Check for modern auth configuration 36 | auth_config = get_auth_config() 37 | if not auth_config: 38 | # If no auth config, return empty components 39 | return {"imports": [], "setup_code": [], "fastmcp_args": {}, "has_auth": False} 40 | 41 | # Validate that we have a modern auth config 42 | if not isinstance(auth_config, AuthConfig): 43 | raise ValueError( 44 | f"Invalid auth configuration type: {type(auth_config).__name__}. " 45 | "Golf 0.2.x requires modern auth configurations (JWTAuthConfig, " 46 | "StaticTokenConfig, OAuthServerConfig, or RemoteAuthConfig). " 47 | "Please update your auth.py file." 48 | ) 49 | 50 | # Generate modern auth components with embedded configuration 51 | auth_imports = [ 52 | "import os", 53 | "import sys", 54 | "from golf.auth.factory import create_auth_provider", 55 | "from golf.auth.providers import RemoteAuthConfig, JWTAuthConfig, StaticTokenConfig, OAuthServerConfig, OAuthProxyConfig", 56 | ] 57 | 58 | # Embed the auth configuration directly in the generated code 59 | # Convert the auth config to its string representation for embedding 60 | auth_config_repr = repr(auth_config) 61 | 62 | setup_code_lines = [ 63 | "# Modern FastMCP 2.11+ authentication setup with embedded configuration", 64 | f"auth_config = {auth_config_repr}", 65 | "try:", 66 | " auth_provider = create_auth_provider(auth_config)", 67 | " # Authentication configured with {auth_config.provider_type} provider", 68 | "except Exception as e:", 69 | " print(f'Authentication setup failed: {e}', file=sys.stderr)", 70 | " auth_provider = None", 71 | "", 72 | ] 73 | 74 | # FastMCP constructor arguments - FastMCP 2.11+ uses auth parameter 75 | fastmcp_args = {"auth": "auth_provider"} 76 | 77 | return { 78 | "imports": auth_imports, 79 | "setup_code": setup_code_lines, 80 | "fastmcp_args": fastmcp_args, 81 | "has_auth": True, 82 | } 83 | 84 | 85 | def generate_api_key_auth_components( 86 | server_name: str, 87 | opentelemetry_enabled: bool = False, 88 | transport: str = "streamable-http", 89 | ) -> dict: 90 | """Generate authentication components for API key authentication. 91 | 92 | Returns a dictionary with: 93 | - imports: List of import statements 94 | - setup_code: Auth setup code (middleware setup) 95 | - fastmcp_args: Dict of arguments to add to FastMCP constructor 96 | - has_auth: Whether auth is configured 97 | """ 98 | api_key_config = get_api_key_config() 99 | if not api_key_config: 100 | return {"imports": [], "setup_code": [], "fastmcp_args": {}, "has_auth": False} 101 | 102 | auth_imports = [ 103 | "# API key authentication setup", 104 | "from golf.auth.api_key import get_api_key_config, configure_api_key", 105 | "from golf.auth import set_api_key", 106 | "from starlette.middleware.base import BaseHTTPMiddleware", 107 | "from starlette.requests import Request", 108 | "from starlette.responses import JSONResponse", 109 | "import os", 110 | ] 111 | 112 | setup_code_lines = [ 113 | "# Recreate API key configuration from auth.py", 114 | "configure_api_key(", 115 | f" header_name={repr(api_key_config.header_name)},", 116 | f" header_prefix={repr(api_key_config.header_prefix)},", 117 | f" required={repr(api_key_config.required)}", 118 | ")", 119 | "", 120 | "# Simplified API key middleware that validates presence", 121 | "class ApiKeyMiddleware(BaseHTTPMiddleware):", 122 | " async def dispatch(self, request: Request, call_next):", 123 | " # Debug mode from environment", 124 | " debug = os.environ.get('API_KEY_DEBUG', '').lower() == 'true'", 125 | " ", 126 | " # Skip auth for monitoring endpoints", 127 | " path = request.url.path", 128 | " if path in ['/metrics', '/health']:", 129 | " return await call_next(request)", 130 | " ", 131 | " api_key_config = get_api_key_config()", 132 | " ", 133 | " if api_key_config:", 134 | " # Extract API key from the configured header", 135 | " header_name = api_key_config.header_name", 136 | " header_prefix = api_key_config.header_prefix", 137 | " ", 138 | " # Case-insensitive header lookup", 139 | " api_key = None", 140 | " for k, v in request.headers.items():", 141 | " if k.lower() == header_name.lower():", 142 | " api_key = v", 143 | " break", 144 | " ", 145 | " # Process the API key if found", 146 | " if api_key:", 147 | " # Strip prefix if configured", 148 | " if header_prefix and api_key.startswith(header_prefix):", 149 | " api_key = api_key[len(header_prefix):]", 150 | " ", 151 | " # Store the API key in request state for tools to access", 152 | " request.state.api_key = api_key", 153 | " ", 154 | " # Also store in context variable for tools", 155 | " set_api_key(api_key)", 156 | " ", 157 | " # Check if API key is required but missing", 158 | " if api_key_config.required and not api_key:", 159 | " return JSONResponse(", 160 | " {'error': 'unauthorized', " 161 | "'detail': f'Missing required {header_name} header'}," 162 | " status_code=401,", 163 | " headers={'WWW-Authenticate': f'{header_name} realm=\"MCP Server\"'}", 164 | " )", 165 | " ", 166 | " # Continue with the request", 167 | " return await call_next(request)", 168 | "", 169 | ] 170 | 171 | # API key auth is handled via middleware, not FastMCP constructor args 172 | fastmcp_args = {} 173 | 174 | return { 175 | "imports": auth_imports, 176 | "setup_code": setup_code_lines, 177 | "fastmcp_args": fastmcp_args, 178 | "has_auth": True, 179 | } 180 | 181 | 182 | def generate_auth_routes() -> str: 183 | """Generate code for auth routes in the FastMCP app. 184 | 185 | Auth providers (RemoteAuthProvider, OAuthProvider) provide OAuth metadata routes 186 | that need to be added to the server. 187 | """ 188 | # API key auth doesn't need special routes 189 | api_key_config = get_api_key_config() 190 | if api_key_config: 191 | return "" 192 | 193 | # Check if auth is configured 194 | if not is_auth_configured(): 195 | return "" 196 | 197 | # Auth providers provide OAuth metadata routes that need to be added to the server 198 | return """ 199 | # Add OAuth metadata routes from auth provider 200 | if auth_provider and hasattr(auth_provider, 'get_routes'): 201 | auth_routes = auth_provider.get_routes() 202 | 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}") 209 | """ 210 | -------------------------------------------------------------------------------- /src/golf/auth/registry.py: -------------------------------------------------------------------------------- 1 | """Provider registry system for extensible authentication providers. 2 | 3 | This module provides a registry-based dispatch system that allows custom 4 | authentication providers to be added without modifying the core factory code. 5 | """ 6 | 7 | from typing import Protocol, TYPE_CHECKING 8 | from abc import ABC, abstractmethod 9 | 10 | if TYPE_CHECKING: 11 | from fastmcp.server.auth.auth import AuthProvider 12 | 13 | from .providers import AuthConfig 14 | 15 | 16 | class AuthProviderFactory(Protocol): 17 | """Protocol for auth provider factory functions. 18 | 19 | Custom provider factories must implement this interface to be compatible 20 | with the registry system. 21 | """ 22 | 23 | def __call__(self, config: AuthConfig) -> "AuthProvider": 24 | """Create an AuthProvider from configuration. 25 | 26 | Args: 27 | config: Authentication configuration object 28 | 29 | Returns: 30 | Configured FastMCP AuthProvider instance 31 | 32 | Raises: 33 | ValueError: If configuration is invalid 34 | ImportError: If required dependencies are missing 35 | """ 36 | ... 37 | 38 | 39 | class BaseProviderPlugin(ABC): 40 | """Base class for auth provider plugins. 41 | 42 | Provider plugins can extend this class to provide both configuration 43 | and factory logic in a single cohesive unit. 44 | """ 45 | 46 | @property 47 | @abstractmethod 48 | def provider_type(self) -> str: 49 | """Return the provider type identifier.""" 50 | ... 51 | 52 | @property 53 | @abstractmethod 54 | def config_class(self) -> type[AuthConfig]: 55 | """Return the configuration class for this provider.""" 56 | ... 57 | 58 | @abstractmethod 59 | def create_provider(self, config: AuthConfig) -> "AuthProvider": 60 | """Create the auth provider from configuration. 61 | 62 | Args: 63 | config: Authentication configuration (must be instance of config_class) 64 | 65 | Returns: 66 | Configured FastMCP AuthProvider instance 67 | """ 68 | ... 69 | 70 | def validate_config(self, config: AuthConfig) -> None: 71 | """Validate the configuration before creating provider. 72 | 73 | Override this method to add custom validation logic. 74 | Default implementation checks config is correct type. 75 | 76 | Args: 77 | config: Configuration to validate 78 | 79 | Raises: 80 | ValueError: If configuration is invalid 81 | """ 82 | if not isinstance(config, self.config_class): 83 | raise ValueError( 84 | f"Expected {self.config_class.__name__} for {self.provider_type} provider, got {type(config).__name__}" 85 | ) 86 | 87 | 88 | class AuthProviderRegistry: 89 | """Registry for authentication provider factories and plugins. 90 | 91 | This registry allows custom authentication providers to be registered 92 | without modifying the core factory code. Providers can be registered 93 | either as simple factory functions or as full plugin classes. 94 | """ 95 | 96 | def __init__(self) -> None: 97 | self._factories: dict[str, AuthProviderFactory] = {} 98 | self._plugins: dict[str, BaseProviderPlugin] = {} 99 | 100 | def register_factory(self, provider_type: str, factory: AuthProviderFactory) -> None: 101 | """Register a factory function for a provider type. 102 | 103 | Args: 104 | provider_type: Unique identifier for the provider type 105 | factory: Factory function that creates providers 106 | 107 | Raises: 108 | ValueError: If provider_type is already registered 109 | """ 110 | if provider_type in self._factories or provider_type in self._plugins: 111 | raise ValueError(f"Provider type '{provider_type}' is already registered") 112 | 113 | self._factories[provider_type] = factory 114 | 115 | def register_plugin(self, plugin: BaseProviderPlugin) -> None: 116 | """Register a provider plugin. 117 | 118 | Args: 119 | plugin: Provider plugin instance 120 | 121 | Raises: 122 | ValueError: If provider type is already registered 123 | """ 124 | provider_type = plugin.provider_type 125 | if provider_type in self._factories or provider_type in self._plugins: 126 | raise ValueError(f"Provider type '{provider_type}' is already registered") 127 | 128 | self._plugins[provider_type] = plugin 129 | 130 | def unregister(self, provider_type: str) -> None: 131 | """Unregister a provider type. 132 | 133 | Args: 134 | provider_type: Provider type to remove 135 | 136 | Raises: 137 | KeyError: If provider type is not registered 138 | """ 139 | if provider_type in self._factories: 140 | del self._factories[provider_type] 141 | elif provider_type in self._plugins: 142 | del self._plugins[provider_type] 143 | else: 144 | raise KeyError(f"Provider type '{provider_type}' is not registered") 145 | 146 | def get_factory(self, provider_type: str) -> AuthProviderFactory: 147 | """Get factory function for a provider type. 148 | 149 | Args: 150 | provider_type: Provider type to look up 151 | 152 | Returns: 153 | Factory function for the provider type 154 | 155 | Raises: 156 | KeyError: If provider type is not registered 157 | """ 158 | # Check factories first 159 | if provider_type in self._factories: 160 | return self._factories[provider_type] 161 | 162 | # Check plugins 163 | if provider_type in self._plugins: 164 | plugin = self._plugins[provider_type] 165 | 166 | # Wrap plugin method to match factory signature 167 | def plugin_factory(config: AuthConfig) -> "AuthProvider": 168 | plugin.validate_config(config) 169 | return plugin.create_provider(config) 170 | 171 | return plugin_factory 172 | 173 | raise KeyError(f"No provider registered for type '{provider_type}'") 174 | 175 | def create_provider(self, config: AuthConfig) -> "AuthProvider": 176 | """Create a provider from configuration using the registry. 177 | 178 | Args: 179 | config: Authentication configuration 180 | 181 | Returns: 182 | Configured AuthProvider instance 183 | 184 | Raises: 185 | KeyError: If provider type is not registered 186 | ValueError: If configuration is invalid 187 | """ 188 | provider_type = getattr(config, "provider_type", None) 189 | if not provider_type: 190 | raise ValueError(f"Configuration {type(config).__name__} missing provider_type attribute") 191 | 192 | factory = self.get_factory(provider_type) 193 | return factory(config) 194 | 195 | def list_providers(self) -> list[str]: 196 | """List all registered provider types. 197 | 198 | Returns: 199 | List of provider type identifiers 200 | """ 201 | return sorted(list(self._factories.keys()) + list(self._plugins.keys())) 202 | 203 | def is_registered(self, provider_type: str) -> bool: 204 | """Check if a provider type is registered. 205 | 206 | Args: 207 | provider_type: Provider type to check 208 | 209 | Returns: 210 | True if provider type is registered 211 | """ 212 | return provider_type in self._factories or provider_type in self._plugins 213 | 214 | 215 | # Global registry instance 216 | _default_registry = AuthProviderRegistry() 217 | 218 | 219 | def get_provider_registry() -> AuthProviderRegistry: 220 | """Get the default provider registry. 221 | 222 | Returns: 223 | Default AuthProviderRegistry instance 224 | """ 225 | return _default_registry 226 | 227 | 228 | def register_provider_factory(provider_type: str, factory: AuthProviderFactory) -> None: 229 | """Register a factory function in the default registry. 230 | 231 | Args: 232 | provider_type: Unique identifier for the provider type 233 | factory: Factory function that creates providers 234 | """ 235 | _default_registry.register_factory(provider_type, factory) 236 | 237 | 238 | def register_provider_plugin(plugin: BaseProviderPlugin) -> None: 239 | """Register a provider plugin in the default registry. 240 | 241 | Args: 242 | plugin: Provider plugin instance 243 | """ 244 | _default_registry.register_plugin(plugin) 245 | 246 | 247 | def create_auth_provider_from_registry(config: AuthConfig) -> "AuthProvider": 248 | """Create an auth provider using the default registry. 249 | 250 | Args: 251 | config: Authentication configuration 252 | 253 | Returns: 254 | Configured AuthProvider instance 255 | """ 256 | return _default_registry.create_provider(config) 257 | -------------------------------------------------------------------------------- /src/golf/core/builder_metrics.py: -------------------------------------------------------------------------------- 1 | """Metrics integration for the GolfMCP build process. 2 | 3 | This module provides functions for generating Prometheus metrics initialization 4 | and collection code for FastMCP servers built with GolfMCP. 5 | """ 6 | 7 | 8 | def generate_metrics_imports() -> list[str]: 9 | """Generate import statements for metrics collection. 10 | 11 | Returns: 12 | List of import statements for metrics 13 | """ 14 | return [ 15 | "# Prometheus metrics imports", 16 | "from golf.metrics import init_metrics, get_metrics_collector", 17 | "from prometheus_client import generate_latest, CONTENT_TYPE_LATEST", 18 | "from starlette.responses import Response", 19 | "from starlette.middleware.base import BaseHTTPMiddleware", 20 | "from starlette.requests import Request", 21 | "import time", 22 | ] 23 | 24 | 25 | def generate_metrics_initialization(server_name: str) -> list[str]: 26 | """Generate metrics initialization code. 27 | 28 | Args: 29 | server_name: Name of the MCP server 30 | 31 | Returns: 32 | List of code lines for metrics initialization 33 | """ 34 | return [ 35 | "# Initialize metrics collection", 36 | "init_metrics(enabled=True)", 37 | "", 38 | ] 39 | 40 | 41 | def generate_metrics_route(metrics_path: str) -> list[str]: 42 | """Generate the metrics endpoint route code. 43 | 44 | Args: 45 | metrics_path: Path for the metrics endpoint (e.g., "/metrics") 46 | 47 | Returns: 48 | List of code lines for the metrics route 49 | """ 50 | return [ 51 | "# Add metrics endpoint", 52 | f'@mcp.custom_route("{metrics_path}", methods=["GET"])', 53 | "async def metrics_endpoint(request):", 54 | ' """Prometheus metrics endpoint for monitoring."""', 55 | " # Update uptime before returning metrics", 56 | " update_uptime()", 57 | " return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)", 58 | "", 59 | ] 60 | 61 | 62 | def generate_metrics_instrumentation() -> list[str]: 63 | """Generate metrics instrumentation wrapper functions. 64 | 65 | Returns: 66 | List of code lines for metrics instrumentation 67 | """ 68 | return [ 69 | "# Metrics instrumentation wrapper functions", 70 | "import time", 71 | "import functools", 72 | "from typing import Any, Callable", 73 | "", 74 | "def instrument_tool(func: Callable, tool_name: str) -> Callable:", 75 | ' """Wrap a tool function with metrics collection."""', 76 | " @functools.wraps(func)", 77 | " async def wrapper(*args, **kwargs) -> Any:", 78 | " collector = get_metrics_collector()", 79 | " start_time = time.time()", 80 | " status = 'success'", 81 | " try:", 82 | " result = await func(*args, **kwargs)", 83 | " return result", 84 | " except Exception as e:", 85 | " status = 'error'", 86 | " collector.increment_error('tool', type(e).__name__)", 87 | " raise", 88 | " finally:", 89 | " duration = time.time() - start_time", 90 | " collector.increment_tool_execution(tool_name, status)", 91 | " collector.record_tool_duration(tool_name, duration)", 92 | " return wrapper", 93 | "", 94 | "def instrument_resource(func: Callable, resource_name: str) -> Callable:", 95 | ' """Wrap a resource function with metrics collection."""', 96 | " @functools.wraps(func)", 97 | " async def wrapper(*args, **kwargs) -> Any:", 98 | " collector = get_metrics_collector()", 99 | " try:", 100 | " result = await func(*args, **kwargs)", 101 | " # Extract URI from args if available for resource_reads metric", 102 | " if args and len(args) > 0:", 103 | " uri = str(args[0]) if args[0] else resource_name", 104 | " else:", 105 | " uri = resource_name", 106 | " collector.increment_resource_read(uri)", 107 | " return result", 108 | " except Exception as e:", 109 | " collector.increment_error('resource', type(e).__name__)", 110 | " raise", 111 | " return wrapper", 112 | "", 113 | "def instrument_prompt(func: Callable, prompt_name: str) -> Callable:", 114 | ' """Wrap a prompt function with metrics collection."""', 115 | " @functools.wraps(func)", 116 | " async def wrapper(*args, **kwargs) -> Any:", 117 | " collector = get_metrics_collector()", 118 | " try:", 119 | " result = await func(*args, **kwargs)", 120 | " collector.increment_prompt_generation(prompt_name)", 121 | " return result", 122 | " except Exception as e:", 123 | " collector.increment_error('prompt', type(e).__name__)", 124 | " raise", 125 | " return wrapper", 126 | "", 127 | "# HTTP Request Metrics Middleware", 128 | "class MetricsMiddleware(BaseHTTPMiddleware):", 129 | ' """Middleware to collect HTTP request metrics."""', 130 | "", 131 | " async def dispatch(self, request: Request, call_next):", 132 | " collector = get_metrics_collector()", 133 | " start_time = time.time()", 134 | " ", 135 | " # Extract path and method", 136 | " method = request.method", 137 | " path = request.url.path", 138 | " ", 139 | " try:", 140 | " response = await call_next(request)", 141 | " status_code = response.status_code", 142 | " except Exception as e:", 143 | " status_code = 500", 144 | " collector.increment_error('http', type(e).__name__)", 145 | " raise", 146 | " finally:", 147 | " duration = time.time() - start_time", 148 | " collector.increment_http_request(method, status_code, path)", 149 | " collector.record_http_duration(method, path, duration)", 150 | " ", 151 | " return response", 152 | "", 153 | "# Session tracking helpers", 154 | "import atexit", 155 | "from contextlib import asynccontextmanager", 156 | "", 157 | "# Global server start time for uptime tracking", 158 | "_server_start_time = time.time()", 159 | "", 160 | "def track_session_start():", 161 | ' """Track when a new session starts."""', 162 | " collector = get_metrics_collector()", 163 | " collector.increment_session()", 164 | "", 165 | "def track_session_end(start_time: float):", 166 | ' """Track when a session ends."""', 167 | " collector = get_metrics_collector()", 168 | " duration = time.time() - start_time", 169 | " collector.record_session_duration(duration)", 170 | "", 171 | "def update_uptime():", 172 | ' """Update the uptime metric."""', 173 | " collector = get_metrics_collector()", 174 | " uptime = time.time() - _server_start_time", 175 | " collector.set_uptime(uptime)", 176 | "", 177 | "# Initialize uptime tracking", 178 | "update_uptime()", 179 | "", 180 | ] 181 | 182 | 183 | def generate_session_tracking() -> list[str]: 184 | """Generate session tracking integration code. 185 | 186 | Returns: 187 | List of code lines for session tracking 188 | """ 189 | return [ 190 | "# Session tracking integration", 191 | "import asyncio", 192 | "from typing import Dict", 193 | "", 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", 200 | " ", 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", 215 | " ", 216 | " mcp._create_session = _patched_create_session", 217 | "except (ImportError, AttributeError):", 218 | " # Fallback: track sessions via request patterns", 219 | " pass", 220 | "", 221 | ] 222 | -------------------------------------------------------------------------------- /src/golf/core/transformer.py: -------------------------------------------------------------------------------- 1 | """Transform GolfMCP components into standalone FastMCP code. 2 | 3 | This module provides utilities for transforming GolfMCP's convention-based code 4 | into explicit FastMCP component registrations. 5 | """ 6 | 7 | import ast 8 | from pathlib import Path 9 | from typing import Any 10 | 11 | from golf.core.parser import ParsedComponent 12 | 13 | 14 | class ImportTransformer(ast.NodeTransformer): 15 | """AST transformer for rewriting imports in component files.""" 16 | 17 | def __init__( 18 | self, 19 | original_path: Path, 20 | target_path: Path, 21 | import_map: dict[str, str], 22 | project_root: Path, 23 | root_file_modules: set[str] | None = None, 24 | ) -> None: 25 | """Initialize the import transformer. 26 | 27 | Args: 28 | original_path: Path to the original file 29 | target_path: Path to the target file 30 | import_map: Mapping of original module paths to generated paths 31 | project_root: Root path of the project 32 | root_file_modules: Set of root file module names (without .py extension) 33 | """ 34 | self.original_path = original_path 35 | self.target_path = target_path 36 | self.import_map = import_map 37 | self.project_root = project_root 38 | self.root_file_modules = root_file_modules or set() 39 | 40 | def _calculate_import_depth(self) -> int: 41 | """Calculate the relative import depth needed to reach build root from component location.""" 42 | try: 43 | # Get component path relative to project root 44 | relative_path = self.target_path.relative_to(self.project_root) 45 | 46 | # Count directory levels: components/tools/weather.py = 2 levels, needs level=2 47 | # components/tools/api/handler.py = 3 levels, needs level=3 48 | # Build root contains the root files, so depth = number of path parts 49 | return len(relative_path.parts) - 1 # Subtract 1 for the filename itself 50 | 51 | except ValueError: 52 | # Fallback to level=3 if path calculation fails 53 | return 3 54 | 55 | def visit_Import(self, node: ast.Import) -> Any: 56 | """Transform import statements.""" 57 | new_names = [] 58 | 59 | for alias in node.names: 60 | module_name = alias.name 61 | 62 | if module_name in self.root_file_modules: 63 | # Keep original import unchanged - sys.path will handle resolution 64 | new_names.append(alias) 65 | continue 66 | else: 67 | new_names.append(alias) 68 | 69 | # If no root modules, return original or modified import 70 | if new_names != list(node.names): 71 | return ast.Import(names=new_names) 72 | return node 73 | 74 | def visit_ImportFrom(self, node: ast.ImportFrom) -> Any: 75 | """Transform import from statements.""" 76 | if node.module is None: 77 | return node 78 | 79 | # Check if this is importing from a root file module 80 | if node.level == 0 and node.module in self.root_file_modules: 81 | # Keep unchanged - sys.path will handle resolution 82 | return node 83 | 84 | # Handle relative imports 85 | if node.level > 0: 86 | # Calculate the source module path 87 | source_dir = self.original_path.parent 88 | for _ in range(node.level - 1): 89 | source_dir = source_dir.parent 90 | 91 | if node.module: 92 | # Handle imports like `from .helpers import utils` 93 | source_module = source_dir / node.module.replace(".", "/") 94 | else: 95 | # Handle imports like `from . import something` 96 | source_module = source_dir 97 | 98 | try: 99 | # Check if this is a shared module import 100 | source_str = str(source_module.relative_to(self.project_root)) 101 | 102 | # First, try direct module path match (e.g., "tools/weather/helpers") 103 | if source_str in self.import_map: 104 | new_module = self.import_map[source_str] 105 | return ast.ImportFrom(module=new_module, names=node.names, level=0) 106 | 107 | # If direct match fails, try directory-based matching 108 | # This handles cases like `from . import common` where the import_map 109 | # has "tools/weather/common" but we're looking for "tools/weather" 110 | source_dir_str = str(source_dir.relative_to(self.project_root)) 111 | if source_dir_str in self.import_map: 112 | new_module = self.import_map[source_dir_str] 113 | if node.module: 114 | new_module = f"{new_module}.{node.module}" 115 | return ast.ImportFrom(module=new_module, names=node.names, level=0) 116 | 117 | # Check for specific module imports within the directory 118 | for import_path, mapped_path in self.import_map.items(): 119 | # Handle cases where we import a specific module from a directory 120 | # e.g., `from .common import something` should match "tools/weather/common" 121 | if import_path.startswith(source_dir_str + "/") and node.module: 122 | module_name = import_path.replace(source_dir_str + "/", "") 123 | if module_name == node.module: 124 | return ast.ImportFrom(module=mapped_path, names=node.names, level=0) 125 | 126 | except ValueError: 127 | # source_module is not relative to project_root, leave import unchanged 128 | pass 129 | 130 | return node 131 | 132 | 133 | def transform_component( 134 | component: ParsedComponent | None, 135 | output_file: Path, 136 | project_path: Path, 137 | import_map: dict[str, str], 138 | source_file: Path | None = None, 139 | root_file_modules: set[str] | None = None, 140 | ) -> str: 141 | """Transform a GolfMCP component into a standalone FastMCP component. 142 | 143 | Args: 144 | component: Parsed component to transform (optional if source_file provided) 145 | output_file: Path to write the transformed component to 146 | project_path: Path to the project root 147 | import_map: Mapping of original module paths to generated paths 148 | source_file: Optional path to source file (for shared files) 149 | root_file_modules: Set of root file module names (without .py extension) 150 | 151 | Returns: 152 | Generated component code 153 | """ 154 | # Read the original file 155 | if source_file is not None: 156 | file_path = source_file 157 | elif component is not None: 158 | file_path = Path(component.file_path) 159 | else: 160 | raise ValueError("Either component or source_file must be provided") 161 | 162 | with open(file_path) as f: 163 | source_code = f.read() 164 | 165 | # Parse the source code into an AST 166 | tree = ast.parse(source_code) 167 | 168 | # Transform imports 169 | transformer = ImportTransformer(file_path, output_file, import_map, project_path, root_file_modules) 170 | tree = transformer.visit(tree) 171 | 172 | # Get all imports and docstring 173 | imports = [] 174 | docstring = None 175 | 176 | # Find the module docstring if present 177 | if ( 178 | len(tree.body) > 0 179 | and isinstance(tree.body[0], ast.Expr) 180 | and isinstance(tree.body[0].value, ast.Constant) 181 | and isinstance(tree.body[0].value.value, str) 182 | ): 183 | docstring = tree.body[0].value.value 184 | 185 | # Find imports 186 | for node in tree.body: 187 | if isinstance(node, ast.Import | ast.ImportFrom): 188 | imports.append(node) 189 | 190 | # Build full transformed code - start with docstring first (Python convention) 191 | transformed_code = "" 192 | 193 | # Add docstring first if present, using proper triple quotes for multi-line docstrings 194 | if docstring: 195 | # Check if docstring contains newlines 196 | if "\n" in docstring: 197 | # Use triple quotes for multi-line docstrings 198 | transformed_code += f'"""{docstring}"""\n\n' 199 | else: 200 | # Use single quotes for single-line docstrings 201 | transformed_code += f'"{docstring}"\n\n' 202 | 203 | # Add transformed imports after docstring 204 | if imports: 205 | transformed_imports = ast.unparse(ast.Module(body=imports, type_ignores=[])) 206 | transformed_code += transformed_imports + "\n\n" 207 | 208 | # Add the rest of the code except imports and the original docstring 209 | remaining_nodes = [] 210 | for node in tree.body: 211 | # Skip imports 212 | if isinstance(node, ast.Import | ast.ImportFrom): 213 | continue 214 | 215 | # Skip the original docstring 216 | if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): 217 | continue 218 | 219 | remaining_nodes.append(node) 220 | 221 | remaining_code = ast.unparse(ast.Module(body=remaining_nodes, type_ignores=[])) 222 | transformed_code += remaining_code 223 | 224 | # Ensure the directory exists 225 | output_file.parent.mkdir(parents=True, exist_ok=True) 226 | 227 | # Write the transformed code to the output file 228 | with open(output_file, "w") as f: 229 | f.write(transformed_code) 230 | 231 | return transformed_code 232 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to GolfMCP 3 | 4 | First off, thanks for taking the time to contribute! ❤️ 5 | 6 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 7 | 8 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 9 | > - Star the project 10 | > - Tweet about it 11 | > - Refer this project in your project's readme 12 | > - Mention the project at local meetups and tell your friends/colleagues 13 | 14 | 15 | ## Table of Contents 16 | 17 | - [Code of Conduct](#code-of-conduct) 18 | - [I Have a Question](#i-have-a-question) 19 | - [I Want To Contribute](#i-want-to-contribute) 20 | - [Reporting Bugs](#reporting-bugs) 21 | - [Suggesting Enhancements](#suggesting-enhancements) 22 | - [Your First Code Contribution](#your-first-code-contribution) 23 | - [Improving The Documentation](#improving-the-documentation) 24 | - [Styleguides](#styleguides) 25 | - [Commit Messages](#commit-messages) 26 | - [Join The Project Team](#join-the-project-team) 27 | 28 | 29 | ## Code of Conduct 30 | 31 | This project and everyone participating in it is governed by the 32 | [GolfMCP Code of Conduct](https://github.com/golf-mcp/golf/blob/main/CODE_OF_CONDUCT.md). 33 | By participating, you are expected to uphold this code. Please report unacceptable behavior 34 | to . 35 | 36 | 37 | ## I Have a Question 38 | 39 | > If you want to ask a question, we assume that you have read the available [Documentation](https://docs.golf.dev). 40 | 41 | Before you ask a question, it is best to search for existing [Issues](https://github.com/golf-mcp/golf/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 42 | 43 | If you then still feel the need to ask a question and need clarification, we recommend the following: 44 | 45 | - Open an [Issue](https://github.com/golf-mcp/golf/issues/new). 46 | - Provide as much context as you can about what you're running into. 47 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 48 | 49 | We will then take care of the issue as soon as possible. 50 | 51 | 65 | 66 | ## I Want To Contribute 67 | 68 | > ### Legal Notice 69 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project licence. 70 | 71 | ### Reporting Bugs 72 | 73 | 74 | #### Before Submitting a Bug Report 75 | 76 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 77 | 78 | - Make sure that you are using the latest version. 79 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://docs.golf.dev). If you are looking for support, you might want to check [this section](#i-have-a-question)). 80 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/golf-mcp/golf/issues?q=label%3Abug). 81 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 82 | - Collect information about the bug: 83 | - Stack trace (Traceback) 84 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 85 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 86 | - Possibly your input and the output 87 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 88 | 89 | 90 | #### How Do I Submit a Good Bug Report? 91 | 92 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . 93 | 94 | 95 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 96 | 97 | - Open an [Issue](https://github.com/golf-mcp/golf/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 98 | - Explain the behavior you would expect and the actual behavior. 99 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 100 | - Provide the information you collected in the previous section. 101 | 102 | Once it's filed: 103 | 104 | - The project team will label the issue accordingly. 105 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 106 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 107 | 108 | 109 | 110 | 111 | ### Suggesting Enhancements 112 | 113 | This section guides you through submitting an enhancement suggestion for golf, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 114 | 115 | 116 | #### Before Submitting an Enhancement 117 | 118 | - Make sure that you are using the latest version. 119 | - Read the [documentation](https://docs.golf.dev) carefully and find out if the functionality is already covered, maybe by an individual configuration. 120 | - Perform a [search](https://github.com/golf-mcp/golf/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 121 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 122 | 123 | 124 | #### How Do I Submit a Good Enhancement Suggestion? 125 | 126 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/golf-mcp/golf/issues). 127 | 128 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 129 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 130 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 131 | - You may want to **include screenshots or screen recordings** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [LICEcap](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and the built-in [screen recorder in GNOME](https://help.gnome.org/users/gnome-help/stable/screen-shot-record.html.en) or [SimpleScreenRecorder](https://github.com/MaartenBaert/ssr) on Linux. 132 | - **Explain why this enhancement would be useful** to most golf users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 133 | 134 | 135 | 136 | ### Your First Code Contribution 137 | 141 | 142 | ### Improving The Documentation 143 | 147 | 148 | ## Styleguides 149 | ### Commit Messages 150 | 153 | 154 | ## Join The Project Team 155 | 156 | 157 | 158 | ## Attribution 159 | This guide is based on the [contributing.md](https://contributing.md/generator)! 160 | -------------------------------------------------------------------------------- /src/golf/commands/init.py: -------------------------------------------------------------------------------- 1 | """Project initialization command implementation.""" 2 | 3 | import shutil 4 | from pathlib import Path 5 | 6 | from rich.console import Console 7 | from rich.progress import Progress, SpinnerColumn, TextColumn 8 | from rich.prompt import Confirm 9 | 10 | from golf.cli.branding import ( 11 | create_success_message, 12 | create_info_panel, 13 | STATUS_ICONS, 14 | GOLF_ORANGE, 15 | ) 16 | 17 | from golf.core.telemetry import ( 18 | track_command, 19 | track_event, 20 | set_telemetry_enabled, 21 | load_telemetry_preference, 22 | ) 23 | 24 | console = Console() 25 | 26 | 27 | def initialize_project( 28 | project_name: str, 29 | output_dir: Path, 30 | ) -> None: 31 | """Initialize a new GolfMCP project. 32 | 33 | Args: 34 | project_name: Name of the project 35 | output_dir: Directory where the project will be created 36 | """ 37 | try: 38 | # Use the basic template by default 39 | template = "basic" 40 | 41 | # Check if directory exists 42 | if output_dir.exists(): 43 | if not output_dir.is_dir(): 44 | console.print(f"[bold red]Error:[/bold red] '{output_dir}' exists but is not a directory.") 45 | track_command( 46 | "init", 47 | success=False, 48 | error_type="NotADirectory", 49 | error_message="Target exists but is not a directory", 50 | ) 51 | return 52 | 53 | # Check if directory is empty 54 | if any(output_dir.iterdir()) and not Confirm.ask( 55 | f"Directory '{output_dir}' is not empty. Continue anyway?", 56 | default=False, 57 | ): 58 | console.print("Initialization cancelled.") 59 | track_event("cli_init_cancelled", {"success": False}) 60 | return 61 | else: 62 | # Create the directory 63 | output_dir.mkdir(parents=True) 64 | 65 | # Find template directory within the installed package 66 | import golf 67 | 68 | package_init_file = Path(golf.__file__) 69 | # The 'examples' directory is now inside the 'golf' package directory 70 | # e.g. golf/examples/basic, so go up one from __init__.py to get to 'golf' 71 | template_dir = package_init_file.parent / "examples" / template 72 | 73 | if not template_dir.exists(): 74 | console.print(f"[bold red]Error:[/bold red] Could not find template '{template}'") 75 | track_command( 76 | "init", 77 | success=False, 78 | error_type="TemplateNotFound", 79 | error_message=f"Template directory not found: {template}", 80 | ) 81 | return 82 | 83 | # Copy template files 84 | with Progress( 85 | SpinnerColumn(), 86 | TextColumn( 87 | f"[bold {GOLF_ORANGE}]{STATUS_ICONS['building']} Creating project structure...[/bold {GOLF_ORANGE}]" 88 | ), 89 | transient=True, 90 | ) as progress: 91 | progress.add_task("copying", total=None) 92 | 93 | # Copy directory structure 94 | _copy_template(template_dir, output_dir, project_name) 95 | 96 | # Ask for telemetry consent 97 | _prompt_for_telemetry_consent() 98 | 99 | # Show success message 100 | console.print() 101 | create_success_message("Project initialized successfully!", console) 102 | 103 | # Show next steps 104 | next_steps = f"cd {output_dir.name}\ngolf build dev\ngolf run" 105 | create_info_panel("Next Steps", next_steps, console) 106 | 107 | # Track successful initialization 108 | track_event("cli_init_success", {"success": True, "template": template}) 109 | except Exception as e: 110 | # Capture error details for telemetry 111 | error_type = type(e).__name__ 112 | error_message = str(e) 113 | 114 | console.print(f"[bold red]Error during initialization:[/bold red] {error_message}") 115 | track_command("init", success=False, error_type=error_type, error_message=error_message) 116 | 117 | # Re-raise to maintain existing behavior 118 | raise 119 | 120 | 121 | def _copy_template(source_dir: Path, target_dir: Path, project_name: str) -> None: 122 | """Copy template files to the target directory, with variable substitution. 123 | 124 | Args: 125 | source_dir: Source template directory 126 | target_dir: Target project directory 127 | project_name: Name of the project (for substitutions) 128 | """ 129 | # Create standard directory structure 130 | (target_dir / "tools").mkdir(exist_ok=True) 131 | (target_dir / "resources").mkdir(exist_ok=True) 132 | (target_dir / "prompts").mkdir(exist_ok=True) 133 | 134 | # Copy all files from the template 135 | for source_path in source_dir.glob("**/*"): 136 | # Skip if directory (we'll create directories as needed) 137 | if source_path.is_dir(): 138 | continue 139 | 140 | # Compute relative path 141 | rel_path = source_path.relative_to(source_dir) 142 | target_path = target_dir / rel_path 143 | 144 | # Create parent directories if needed 145 | target_path.parent.mkdir(parents=True, exist_ok=True) 146 | 147 | # Copy and substitute content for text files 148 | if _is_text_file(source_path): 149 | with open(source_path, encoding="utf-8") as f: 150 | content = f.read() 151 | 152 | # Replace template variables 153 | content = content.replace("{{project_name}}", project_name) 154 | content = content.replace("{{project_name_lowercase}}", project_name.lower()) 155 | 156 | with open(target_path, "w", encoding="utf-8") as f: 157 | f.write(content) 158 | else: 159 | # Binary file, just copy 160 | shutil.copy2(source_path, target_path) 161 | 162 | # Create a .gitignore if it doesn't exist 163 | gitignore_file = target_dir / ".gitignore" 164 | if not gitignore_file.exists(): 165 | with open(gitignore_file, "w", encoding="utf-8") as f: 166 | f.write("# Python\n") 167 | f.write("__pycache__/\n") 168 | f.write("*.py[cod]\n") 169 | f.write("*$py.class\n") 170 | f.write("*.so\n") 171 | f.write(".Python\n") 172 | f.write("env/\n") 173 | f.write("build/\n") 174 | f.write("develop-eggs/\n") 175 | f.write("dist/\n") 176 | f.write("downloads/\n") 177 | f.write("eggs/\n") 178 | f.write(".eggs/\n") 179 | f.write("lib/\n") 180 | f.write("lib64/\n") 181 | f.write("parts/\n") 182 | f.write("sdist/\n") 183 | f.write("var/\n") 184 | f.write("*.egg-info/\n") 185 | f.write(".installed.cfg\n") 186 | f.write("*.egg\n\n") 187 | f.write("# Environment\n") 188 | f.write(".env\n") 189 | f.write(".venv\n") 190 | f.write("env/\n") 191 | f.write("venv/\n") 192 | f.write("ENV/\n") 193 | f.write("env.bak/\n") 194 | f.write("venv.bak/\n\n") 195 | f.write("# GolfMCP\n") 196 | f.write(".golf/\n") 197 | f.write("dist/\n") 198 | 199 | 200 | def _prompt_for_telemetry_consent() -> None: 201 | """Prompt user for telemetry consent and save their preference.""" 202 | import os 203 | 204 | # Skip prompt in test mode, when telemetry is explicitly disabled, or if 205 | # preference already exists 206 | if os.environ.get("GOLF_TEST_MODE", "").lower() in ("1", "true", "yes", "on"): 207 | return 208 | 209 | # Skip if telemetry is explicitly disabled in environment 210 | if os.environ.get("GOLF_TELEMETRY", "").lower() in ("0", "false", "no", "off"): 211 | return 212 | 213 | # Check if user already has a saved preference 214 | existing_preference = load_telemetry_preference() 215 | if existing_preference is not None: 216 | return # User already made a choice 217 | 218 | console.print() 219 | console.rule("[bold blue]Anonymous usage analytics[/bold blue]", style="blue") 220 | console.print() 221 | console.print("Golf can collect [bold]anonymous usage analytics[/bold] to help improve the tool.") 222 | console.print() 223 | console.print("[dim]What we collect:[/dim]") 224 | console.print(" • Command usage (init, build, run)") 225 | console.print(" • Error types (to fix bugs)") 226 | console.print(" • Golf version and Python version") 227 | console.print(" • Operating system type") 228 | console.print() 229 | console.print("[dim]What we DON'T collect:[/dim]") 230 | console.print(" • Your code or project content") 231 | console.print(" • File paths or project names") 232 | console.print(" • Personal information") 233 | console.print(" • IP addresses") 234 | console.print() 235 | console.print("You can change this anytime by setting GOLF_TELEMETRY=0 in your environment.") 236 | console.print() 237 | 238 | enable_telemetry = Confirm.ask("[bold]Enable anonymous usage analytics?[/bold]", default=False) 239 | 240 | set_telemetry_enabled(enable_telemetry, persist=True) 241 | 242 | if enable_telemetry: 243 | console.print("[green]✓[/green] Anonymous analytics enabled") 244 | else: 245 | console.print("[yellow]○[/yellow] Anonymous analytics disabled") 246 | console.print() 247 | 248 | 249 | def _is_text_file(path: Path) -> bool: 250 | """Check if a file is a text file that needs variable substitution. 251 | 252 | Args: 253 | path: Path to check 254 | 255 | Returns: 256 | True if the file is a text file 257 | """ 258 | # List of known text file extensions 259 | text_extensions = { 260 | ".py", 261 | ".md", 262 | ".txt", 263 | ".html", 264 | ".css", 265 | ".js", 266 | ".json", 267 | ".yml", 268 | ".yaml", 269 | ".toml", 270 | ".ini", 271 | ".cfg", 272 | ".env", 273 | ".example", 274 | } 275 | 276 | # Check if the file has a text extension 277 | if path.suffix in text_extensions: 278 | return True 279 | 280 | # Check specific filenames without extensions 281 | if path.name in {".gitignore", "README", "LICENSE"}: 282 | return True 283 | 284 | # Try to detect if it's a text file by reading a bit of it 285 | try: 286 | with open(path, encoding="utf-8") as f: 287 | f.read(1024) 288 | return True 289 | except UnicodeDecodeError: 290 | return False 291 | -------------------------------------------------------------------------------- /src/golf/auth/__init__.py: -------------------------------------------------------------------------------- 1 | """Modern authentication for Golf MCP servers using FastMCP 2.11+ providers. 2 | 3 | This module provides authentication configuration and utilities for Golf servers, 4 | leveraging FastMCP's built-in authentication system with JWT verification, 5 | OAuth providers, and token management. 6 | """ 7 | 8 | from typing import Any 9 | 10 | # Modern auth provider configurations and factory functions 11 | from .providers import ( 12 | AuthConfig, 13 | JWTAuthConfig, 14 | StaticTokenConfig, 15 | OAuthServerConfig, 16 | RemoteAuthConfig, 17 | OAuthProxyConfig, 18 | ) 19 | from .factory import ( 20 | create_auth_provider, 21 | create_simple_jwt_provider, 22 | create_dev_token_provider, 23 | ) 24 | from .registry import ( 25 | BaseProviderPlugin, 26 | AuthProviderFactory, 27 | get_provider_registry, 28 | register_provider_factory, 29 | register_provider_plugin, 30 | ) 31 | 32 | # Re-export for backward compatibility 33 | from .api_key import configure_api_key, get_api_key_config, is_api_key_configured 34 | from .helpers import ( 35 | extract_token_from_header, 36 | get_api_key, 37 | get_auth_token, 38 | set_api_key, 39 | ) 40 | 41 | # Public API 42 | __all__ = [ 43 | # Main configuration functions 44 | "configure_auth", 45 | "configure_jwt_auth", 46 | "configure_dev_auth", 47 | "configure_oauth_proxy", 48 | "get_auth_config", 49 | # Provider configurations 50 | "AuthConfig", 51 | "JWTAuthConfig", 52 | "StaticTokenConfig", 53 | "OAuthServerConfig", 54 | "RemoteAuthConfig", 55 | "OAuthProxyConfig", 56 | # Factory functions 57 | "create_auth_provider", 58 | "create_simple_jwt_provider", 59 | "create_dev_token_provider", 60 | # Provider registry and plugins 61 | "BaseProviderPlugin", 62 | "AuthProviderFactory", 63 | "get_provider_registry", 64 | "register_provider_factory", 65 | "register_provider_plugin", 66 | # API key functions (backward compatibility) 67 | "configure_api_key", 68 | "get_api_key_config", 69 | "is_api_key_configured", 70 | # Helper functions 71 | "extract_token_from_header", 72 | "get_api_key", 73 | "get_auth_token", 74 | "set_api_key", 75 | ] 76 | 77 | # Global storage for auth configuration 78 | _auth_config: AuthConfig | None = None 79 | 80 | 81 | def configure_auth(config: AuthConfig) -> None: 82 | """Configure authentication for the Golf server. 83 | 84 | This function should be called in auth.py to set up authentication 85 | using FastMCP's modern auth providers. 86 | 87 | Args: 88 | config: Authentication configuration (JWT, OAuth, Static, or Remote) 89 | The required_scopes should be specified in the config itself. 90 | 91 | Examples: 92 | # JWT authentication with Auth0 93 | from golf.auth import configure_auth, JWTAuthConfig 94 | 95 | configure_auth( 96 | JWTAuthConfig( 97 | jwks_uri="https://your-domain.auth0.com/.well-known/jwks.json", 98 | issuer="https://your-domain.auth0.com/", 99 | audience="https://your-api.example.com", 100 | required_scopes=["read:data"], 101 | ) 102 | ) 103 | 104 | # Development with static tokens 105 | from golf.auth import configure_auth, StaticTokenConfig 106 | 107 | configure_auth( 108 | StaticTokenConfig( 109 | tokens={ 110 | "dev-token-123": { 111 | "client_id": "dev-client", 112 | "scopes": ["read", "write"], 113 | } 114 | }, 115 | required_scopes=["read"], 116 | ) 117 | ) 118 | 119 | # Full OAuth server 120 | from golf.auth import configure_auth, OAuthServerConfig 121 | 122 | configure_auth( 123 | OAuthServerConfig( 124 | base_url="https://your-server.example.com", 125 | valid_scopes=["read", "write", "admin"], 126 | default_scopes=["read"], 127 | required_scopes=["read"], 128 | ) 129 | ) 130 | """ 131 | global _auth_config 132 | _auth_config = config 133 | 134 | 135 | def configure_jwt_auth( 136 | *, 137 | jwks_uri: str | None = None, 138 | public_key: str | None = None, 139 | issuer: str | None = None, 140 | audience: str | list[str] | None = None, 141 | required_scopes: list[str] | None = None, 142 | **env_vars: str, 143 | ) -> None: 144 | """Convenience function to configure JWT authentication. 145 | 146 | Args: 147 | jwks_uri: JWKS URI for key fetching 148 | public_key: Static public key (PEM format) 149 | issuer: Expected issuer claim 150 | audience: Expected audience claim(s) 151 | required_scopes: Required scopes for all requests 152 | **env_vars: Environment variable names (public_key_env_var, 153 | jwks_uri_env_var, etc.) 154 | """ 155 | config = JWTAuthConfig( 156 | jwks_uri=jwks_uri, 157 | public_key=public_key, 158 | issuer=issuer, 159 | audience=audience, 160 | required_scopes=required_scopes or [], 161 | **env_vars, 162 | ) 163 | configure_auth(config) 164 | 165 | 166 | def configure_dev_auth( 167 | tokens: dict[str, Any] | None = None, 168 | required_scopes: list[str] | None = None, 169 | ) -> None: 170 | """Convenience function to configure development authentication. 171 | 172 | Args: 173 | tokens: Token dictionary or None for defaults 174 | required_scopes: Required scopes for all requests 175 | """ 176 | if tokens is None: 177 | tokens = { 178 | "dev-token-123": { 179 | "client_id": "dev-client", 180 | "scopes": ["read", "write"], 181 | }, 182 | "admin-token-456": { 183 | "client_id": "admin-client", 184 | "scopes": ["read", "write", "admin"], 185 | }, 186 | } 187 | 188 | config = StaticTokenConfig( 189 | tokens=tokens, 190 | required_scopes=required_scopes or [], 191 | ) 192 | configure_auth(config) 193 | 194 | 195 | def configure_oauth_proxy( 196 | authorization_endpoint: str | None = None, 197 | token_endpoint: str | None = None, 198 | client_id: str | None = None, 199 | client_secret: str | None = None, 200 | base_url: str | None = None, 201 | token_verifier_config: JWTAuthConfig | StaticTokenConfig | None = None, 202 | scopes_supported: list[str] | None = None, 203 | revocation_endpoint: str | None = None, 204 | redirect_path: str = "/oauth/callback", 205 | **env_vars: str, 206 | ) -> None: 207 | """Configure OAuth proxy authentication for non-DCR providers. 208 | 209 | All parameters can be provided either directly or via environment variables. 210 | For each parameter, you can provide the value directly or use the 211 | corresponding *_env_var parameter to specify an environment variable name. 212 | 213 | Examples: 214 | # Direct values (backward compatible) 215 | configure_oauth_proxy( 216 | authorization_endpoint="https://auth.example.com/authorize", 217 | token_endpoint="https://auth.example.com/token", 218 | client_id="my-client", 219 | client_secret="my-secret", 220 | base_url="https://myserver.com", 221 | token_verifier_config=jwt_config, 222 | ) 223 | 224 | # Environment variables only (new behavior) 225 | configure_oauth_proxy( 226 | authorization_endpoint_env_var="OAUTH_AUTH_ENDPOINT", 227 | token_endpoint_env_var="OAUTH_TOKEN_ENDPOINT", 228 | client_id_env_var="OAUTH_CLIENT_ID", 229 | client_secret_env_var="OAUTH_CLIENT_SECRET", 230 | base_url_env_var="OAUTH_BASE_URL", 231 | token_verifier_config=jwt_config, 232 | ) 233 | 234 | # Mixed (direct values with env var overrides) 235 | configure_oauth_proxy( 236 | authorization_endpoint="https://default.example.com/authorize", 237 | authorization_endpoint_env_var="OAUTH_AUTH_ENDPOINT", # Overrides at runtime 238 | # ... 239 | ) 240 | 241 | Args: 242 | authorization_endpoint: OAuth provider's authorization endpoint URL 243 | token_endpoint: OAuth provider's token endpoint URL 244 | client_id: Your registered client ID with the OAuth provider 245 | client_secret: Your registered client secret with the OAuth provider 246 | base_url: Public URL of this OAuth proxy server 247 | token_verifier_config: JWT or Static token configuration for verifying tokens 248 | scopes_supported: List of OAuth scopes this proxy supports 249 | revocation_endpoint: Optional token revocation endpoint 250 | redirect_path: OAuth callback path (default: "/oauth/callback") 251 | **env_vars: Environment variable names for runtime configuration 252 | - authorization_endpoint_env_var: Env var for authorization endpoint 253 | - token_endpoint_env_var: Env var for token endpoint 254 | - client_id_env_var: Env var for client ID 255 | - client_secret_env_var: Env var for client secret 256 | - base_url_env_var: Env var for base URL 257 | - revocation_endpoint_env_var: Env var for revocation endpoint 258 | 259 | Raises: 260 | ValueError: If token_verifier_config is not provided or invalid 261 | ValueError: If required fields lack both direct value and env var 262 | """ 263 | # Validate token_verifier_config is provided (always required) 264 | if token_verifier_config is None: 265 | raise ValueError("token_verifier_config is required and must be JWTAuthConfig or StaticTokenConfig") 266 | 267 | if not isinstance(token_verifier_config, (JWTAuthConfig, StaticTokenConfig)): 268 | raise ValueError( 269 | f"token_verifier_config must be JWTAuthConfig or StaticTokenConfig, " 270 | f"got {type(token_verifier_config).__name__}" 271 | ) 272 | 273 | # Create config with all parameters (None values are OK now) 274 | config = OAuthProxyConfig( 275 | authorization_endpoint=authorization_endpoint, 276 | token_endpoint=token_endpoint, 277 | client_id=client_id, 278 | client_secret=client_secret, 279 | revocation_endpoint=revocation_endpoint, 280 | base_url=base_url, 281 | redirect_path=redirect_path, 282 | scopes_supported=scopes_supported, 283 | token_verifier_config=token_verifier_config, 284 | **env_vars, 285 | ) 286 | configure_auth(config) 287 | 288 | 289 | def get_auth_config() -> AuthConfig | None: 290 | """Get the current auth configuration. 291 | 292 | Returns: 293 | AuthConfig if configured, None otherwise 294 | """ 295 | return _auth_config 296 | 297 | 298 | def is_auth_configured() -> bool: 299 | """Check if authentication is configured. 300 | 301 | Returns: 302 | True if authentication is configured, False otherwise 303 | """ 304 | return _auth_config is not None 305 | 306 | 307 | # Breaking change in Golf 0.2.x: Legacy auth system removed 308 | # Users must migrate to modern auth configurations 309 | 310 | 311 | def create_auth_provider_from_config() -> object | None: 312 | """Create an auth provider from the current configuration. 313 | 314 | Returns: 315 | FastMCP AuthProvider instance or None if not configured 316 | """ 317 | config = get_auth_config() 318 | if not config: 319 | return None 320 | 321 | return create_auth_provider(config) 322 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2025] [Antoni Gmitruk] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/golf/metrics/collector.py: -------------------------------------------------------------------------------- 1 | """Metrics collector for Golf MCP servers.""" 2 | 3 | from typing import Optional 4 | 5 | # Global metrics collector instance 6 | _metrics_collector: Optional["MetricsCollector"] = None 7 | 8 | 9 | class MetricsCollector: 10 | """Collects metrics for Golf MCP servers using Prometheus client.""" 11 | 12 | def __init__(self, enabled: bool = False) -> None: 13 | """Initialize the metrics collector. 14 | 15 | Args: 16 | enabled: Whether metrics collection is enabled 17 | """ 18 | self.enabled = enabled 19 | self._metrics = {} 20 | 21 | if self.enabled: 22 | self._init_prometheus_metrics() 23 | 24 | def _init_prometheus_metrics(self) -> None: 25 | """Initialize Prometheus metrics if enabled.""" 26 | try: 27 | from prometheus_client import Counter, Histogram, Gauge 28 | 29 | # Tool execution metrics 30 | self._metrics["tool_executions"] = Counter( 31 | "golf_tool_executions_total", 32 | "Total number of tool executions", 33 | ["tool_name", "status"], 34 | ) 35 | 36 | self._metrics["tool_duration"] = Histogram( 37 | "golf_tool_duration_seconds", 38 | "Tool execution duration in seconds", 39 | ["tool_name"], 40 | ) 41 | 42 | # HTTP request metrics 43 | self._metrics["http_requests"] = Counter( 44 | "golf_http_requests_total", 45 | "Total number of HTTP requests", 46 | ["method", "status_code", "path"], 47 | ) 48 | 49 | self._metrics["http_duration"] = Histogram( 50 | "golf_http_request_duration_seconds", 51 | "HTTP request duration in seconds", 52 | ["method", "path"], 53 | ) 54 | 55 | # Resource access metrics 56 | self._metrics["resource_reads"] = Counter( 57 | "golf_resource_reads_total", 58 | "Total number of resource reads", 59 | ["resource_uri"], 60 | ) 61 | 62 | # Prompt generation metrics 63 | self._metrics["prompt_generations"] = Counter( 64 | "golf_prompt_generations_total", 65 | "Total number of prompt generations", 66 | ["prompt_name"], 67 | ) 68 | 69 | # Sampling metrics 70 | self._metrics["sampling_requests"] = Counter( 71 | "golf_sampling_requests_total", 72 | "Total number of sampling requests", 73 | ["sampling_type", "status"], 74 | ) 75 | 76 | self._metrics["sampling_duration"] = Histogram( 77 | "golf_sampling_duration_seconds", 78 | "Sampling request duration in seconds", 79 | ["sampling_type"], 80 | ) 81 | 82 | self._metrics["sampling_tokens"] = Histogram( 83 | "golf_sampling_tokens", 84 | "Number of tokens in sampling responses", 85 | ["sampling_type"], 86 | ) 87 | 88 | # Elicitation metrics 89 | self._metrics["elicitation_requests"] = Counter( 90 | "golf_elicitation_requests_total", 91 | "Total number of elicitation requests", 92 | ["elicitation_type", "status"], 93 | ) 94 | 95 | self._metrics["elicitation_duration"] = Histogram( 96 | "golf_elicitation_duration_seconds", 97 | "Elicitation request duration in seconds", 98 | ["elicitation_type"], 99 | ) 100 | 101 | # Error metrics 102 | self._metrics["errors"] = Counter( 103 | "golf_errors_total", 104 | "Total number of errors", 105 | ["component_type", "error_type"], 106 | ) 107 | 108 | # Session metrics 109 | self._metrics["sessions_total"] = Counter("golf_sessions_total", "Total number of sessions created") 110 | 111 | self._metrics["session_duration"] = Histogram( 112 | "golf_session_duration_seconds", "Session duration in seconds" 113 | ) 114 | 115 | # System metrics 116 | self._metrics["uptime"] = Gauge("golf_uptime_seconds", "Server uptime in seconds") 117 | 118 | except ImportError: 119 | # Prometheus client not available, disable metrics 120 | self.enabled = False 121 | 122 | def increment_tool_execution(self, tool_name: str, status: str) -> None: 123 | """Record a tool execution. 124 | 125 | Args: 126 | tool_name: Name of the tool that was executed 127 | status: Execution status ('success' or 'error') 128 | """ 129 | if not self.enabled or "tool_executions" not in self._metrics: 130 | return 131 | 132 | self._metrics["tool_executions"].labels(tool_name=tool_name, status=status).inc() 133 | 134 | def record_tool_duration(self, tool_name: str, duration: float) -> None: 135 | """Record tool execution duration. 136 | 137 | Args: 138 | tool_name: Name of the tool 139 | duration: Execution duration in seconds 140 | """ 141 | if not self.enabled or "tool_duration" not in self._metrics: 142 | return 143 | 144 | self._metrics["tool_duration"].labels(tool_name=tool_name).observe(duration) 145 | 146 | def increment_http_request(self, method: str, status_code: int, path: str) -> None: 147 | """Record an HTTP request. 148 | 149 | Args: 150 | method: HTTP method (GET, POST, etc.) 151 | status_code: HTTP status code 152 | path: Request path 153 | """ 154 | if not self.enabled or "http_requests" not in self._metrics: 155 | return 156 | 157 | self._metrics["http_requests"].labels(method=method, status_code=str(status_code), path=path).inc() 158 | 159 | def record_http_duration(self, method: str, path: str, duration: float) -> None: 160 | """Record HTTP request duration. 161 | 162 | Args: 163 | method: HTTP method 164 | path: Request path 165 | duration: Request duration in seconds 166 | """ 167 | if not self.enabled or "http_duration" not in self._metrics: 168 | return 169 | 170 | self._metrics["http_duration"].labels(method=method, path=path).observe(duration) 171 | 172 | def increment_resource_read(self, resource_uri: str) -> None: 173 | """Record a resource read. 174 | 175 | Args: 176 | resource_uri: URI of the resource that was read 177 | """ 178 | if not self.enabled or "resource_reads" not in self._metrics: 179 | return 180 | 181 | self._metrics["resource_reads"].labels(resource_uri=resource_uri).inc() 182 | 183 | def increment_prompt_generation(self, prompt_name: str) -> None: 184 | """Record a prompt generation. 185 | 186 | Args: 187 | prompt_name: Name of the prompt that was generated 188 | """ 189 | if not self.enabled or "prompt_generations" not in self._metrics: 190 | return 191 | 192 | self._metrics["prompt_generations"].labels(prompt_name=prompt_name).inc() 193 | 194 | def increment_error(self, component_type: str, error_type: str) -> None: 195 | """Record an error. 196 | 197 | Args: 198 | component_type: Type of component ('tool', 'resource', 'prompt', 'http') 199 | error_type: Type of error ('timeout', 'auth_error', 200 | 'validation_error', etc.) 201 | """ 202 | if not self.enabled or "errors" not in self._metrics: 203 | return 204 | 205 | self._metrics["errors"].labels(component_type=component_type, error_type=error_type).inc() 206 | 207 | def increment_session(self) -> None: 208 | """Record a new session.""" 209 | if not self.enabled or "sessions_total" not in self._metrics: 210 | return 211 | 212 | self._metrics["sessions_total"].inc() 213 | 214 | def record_session_duration(self, duration: float) -> None: 215 | """Record session duration. 216 | 217 | Args: 218 | duration: Session duration in seconds 219 | """ 220 | if not self.enabled or "session_duration" not in self._metrics: 221 | return 222 | 223 | self._metrics["session_duration"].observe(duration) 224 | 225 | def set_uptime(self, seconds: float) -> None: 226 | """Set the server uptime. 227 | 228 | Args: 229 | seconds: Server uptime in seconds 230 | """ 231 | if not self.enabled or "uptime" not in self._metrics: 232 | return 233 | 234 | self._metrics["uptime"].set(seconds) 235 | 236 | def increment_sampling(self, sampling_type: str, status: str) -> None: 237 | """Record a sampling request. 238 | 239 | Args: 240 | sampling_type: Type of sampling ('sample', 'structured', 'context') 241 | status: Request status ('success' or 'error') 242 | """ 243 | if not self.enabled or "sampling_requests" not in self._metrics: 244 | return 245 | 246 | self._metrics["sampling_requests"].labels(sampling_type=sampling_type, status=status).inc() 247 | 248 | def record_sampling_duration(self, sampling_type: str, duration: float) -> None: 249 | """Record sampling request duration. 250 | 251 | Args: 252 | sampling_type: Type of sampling 253 | duration: Request duration in seconds 254 | """ 255 | if not self.enabled or "sampling_duration" not in self._metrics: 256 | return 257 | 258 | self._metrics["sampling_duration"].labels(sampling_type=sampling_type).observe(duration) 259 | 260 | def record_sampling_tokens(self, sampling_type: str, token_count: int) -> None: 261 | """Record sampling token count. 262 | 263 | Args: 264 | sampling_type: Type of sampling 265 | token_count: Number of tokens in the response 266 | """ 267 | if not self.enabled or "sampling_tokens" not in self._metrics: 268 | return 269 | 270 | self._metrics["sampling_tokens"].labels(sampling_type=sampling_type).observe(token_count) 271 | 272 | def increment_elicitation(self, elicitation_type: str, status: str) -> None: 273 | """Record an elicitation request. 274 | 275 | Args: 276 | elicitation_type: Type of elicitation ('elicit', 'confirmation') 277 | status: Request status ('success' or 'error') 278 | """ 279 | if not self.enabled or "elicitation_requests" not in self._metrics: 280 | return 281 | 282 | self._metrics["elicitation_requests"].labels(elicitation_type=elicitation_type, status=status).inc() 283 | 284 | def record_elicitation_duration(self, elicitation_type: str, duration: float) -> None: 285 | """Record elicitation request duration. 286 | 287 | Args: 288 | elicitation_type: Type of elicitation 289 | duration: Request duration in seconds 290 | """ 291 | if not self.enabled or "elicitation_duration" not in self._metrics: 292 | return 293 | 294 | self._metrics["elicitation_duration"].labels(elicitation_type=elicitation_type).observe(duration) 295 | 296 | 297 | def init_metrics_collector(enabled: bool = False) -> MetricsCollector: 298 | """Initialize the global metrics collector. 299 | 300 | Args: 301 | enabled: Whether to enable metrics collection 302 | 303 | Returns: 304 | The initialized metrics collector 305 | """ 306 | global _metrics_collector 307 | _metrics_collector = MetricsCollector(enabled=enabled) 308 | return _metrics_collector 309 | 310 | 311 | def get_metrics_collector() -> MetricsCollector: 312 | """Get the global metrics collector instance. 313 | 314 | Returns: 315 | The metrics collector, or a disabled one if not initialized 316 | """ 317 | global _metrics_collector 318 | if _metrics_collector is None: 319 | _metrics_collector = MetricsCollector(enabled=False) 320 | return _metrics_collector 321 | --------------------------------------------------------------------------------