├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── SECURITY.md ├── pull_request_template.md └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── golf-banner.png ├── pyproject.toml ├── src └── golf │ ├── __init__.py │ ├── auth │ ├── __init__.py │ ├── api_key.py │ ├── helpers.py │ ├── oauth.py │ └── provider.py │ ├── cli │ ├── __init__.py │ └── main.py │ ├── commands │ ├── __init__.py │ ├── build.py │ ├── init.py │ └── run.py │ ├── core │ ├── __init__.py │ ├── builder.py │ ├── builder_auth.py │ ├── builder_telemetry.py │ ├── config.py │ ├── parser.py │ ├── platform.py │ ├── telemetry.py │ └── transformer.py │ ├── examples │ ├── __init__.py │ ├── api_key │ │ ├── .env.example │ │ ├── README.md │ │ ├── golf.json │ │ ├── pre_build.py │ │ └── tools │ │ │ ├── issues │ │ │ ├── create.py │ │ │ └── list.py │ │ │ ├── repos │ │ │ └── list.py │ │ │ ├── search │ │ │ └── code.py │ │ │ └── users │ │ │ └── get.py │ └── basic │ │ ├── .env.example │ │ ├── README.md │ │ ├── golf.json │ │ ├── pre_build.py │ │ ├── prompts │ │ └── welcome.py │ │ ├── resources │ │ ├── current_time.py │ │ ├── info.py │ │ └── weather │ │ │ ├── common.py │ │ │ ├── current.py │ │ │ └── forecast.py │ │ └── tools │ │ ├── github_user.py │ │ ├── hello.py │ │ └── payments │ │ ├── charge.py │ │ ├── common.py │ │ └── refund.py │ └── telemetry │ ├── __init__.py │ └── instrumentation.py └── tests ├── __init__.py ├── auth ├── __init__.py └── test_api_key.py ├── commands ├── __init__.py └── test_init.py ├── conftest.py ├── core ├── __init__.py ├── test_builder.py ├── test_config.py ├── test_instrumentation.py ├── test_parser.py ├── test_platform.py └── test_telemetry.py ├── fixtures └── __init__.py └── integration ├── __init__.py └── test_health_check.py /.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 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to authed 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 | [authed Code of Conduct](https://github.com/authed-dev/authed/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.getauthed.dev). 40 | 41 | Before you ask a question, it is best to search for existing [Issues](https://github.com/authed-dev/authed/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/authed-dev/authed/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.getauthed.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/authed-dev/authed/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/authed-dev/authed/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 authed, **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.getauthed.dev) carefully and find out if the functionality is already covered, maybe by an individual configuration. 120 | - Perform a [search](https://github.com/authed-dev/authed/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/authed-dev/authed/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 authed 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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 0.1.16 | :white_check_mark: | 8 | | 0.1.15 | :white_check_mark: | 9 | | 0.1.14 | :white_check_mark: | 10 | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | We take the security of Golf seriously. If you believe you've found a security vulnerability, please follow these steps: 15 | 16 | 1. **Do not disclose the vulnerability publicly** or to any third parties. 17 | 18 | 2. **Email us directly** at founders@golf.dev with details of the vulnerability. 19 | 20 | 3. **Include the following information** in your report: 21 | - Description of the vulnerability 22 | - Steps to reproduce 23 | - Potential impact 24 | - Any suggestions for mitigation 25 | 26 | 4. We will acknowledge receipt of your vulnerability report within 48 hours and provide an estimated timeline for a fix. 27 | 28 | 5. Once the vulnerability is fixed, we will notify you and publicly acknowledge your contribution (unless you prefer to remain anonymous). -------------------------------------------------------------------------------- /.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) 20 | 21 | ## How Has This Been Tested? 22 | 23 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. 24 | 25 | ## Checklist: 26 | 27 | - [ ] My code follows the style guidelines of this project 28 | - [ ] I have performed a self-review of my own code 29 | - [ ] I have commented my code, particularly in hard-to-understand areas 30 | - [ ] My changes generate no new warnings 31 | - [ ] I have added tests that prove my fix is effective or that my feature works 32 | - [ ] Any dependent changes have been merged and published in downstream modules 33 | - [ ] I have updated version numbers as needed (if needed) 34 | 35 | ## Security Considerations: 36 | 37 | - [ ] My changes do not introduce any new security vulnerabilities 38 | - [ ] I have considered the security implications of my changes 39 | 40 | 41 | ## Additional Information: 42 | 43 | Any additional information, configuration or data that might be necessary to reproduce the issue or use the feature. -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | # Ensure telemetry is disabled in CI 11 | GOLF_TELEMETRY: "0" 12 | GOLF_TEST_MODE: "1" 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: "3.11" 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -e ".[telemetry]" 30 | pip install pytest pytest-asyncio pytest-cov 31 | 32 | - name: Run tests 33 | run: | 34 | python -m pytest tests/ -v --cov=golf --cov-report=xml 35 | 36 | - name: Upload coverage to Codecov 37 | uses: codecov/codecov-action@v3 38 | with: 39 | file: ./coverage.xml 40 | fail_ci_if_error: false 41 | 42 | lint: 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - uses: actions/checkout@v3 47 | 48 | - name: Set up Python 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: "3.11" 52 | 53 | - name: Install dependencies 54 | run: | 55 | python -m pip install --upgrade pip 56 | pip install -e ".[telemetry]" 57 | pip install ruff mypy 58 | 59 | - name: Run ruff format check 60 | run: ruff format --check src/ tests/ 61 | 62 | # - name: Run ruff lint 63 | # run: ruff check src/ tests/ 64 | 65 | # - name: Run mypy 66 | # run: mypy src/golf --ignore-missing-imports -------------------------------------------------------------------------------- /.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/ -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 FastMCP server, minimizing boilerplate and accelerating development. 32 | 33 | With Golf, you can focus on implementing your agent's logic rather than wrestling with server setup and integration complexities. It's built for developers who want a quick, organized way to build powerful MCP servers. 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 FastMCP server, typically on `http://127.0.0.1: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 | └─ pre_build.py # (Optional) Script for pre-build hooks (e.g., auth setup) 89 | ``` 90 | 91 | - **`golf.json`**: Configures server name, port, transport, telemetry, and other build settings. 92 | - **`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. 93 | - 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 hyphens). 94 | - **`common.py`** (not shown, but can be placed in subdirectories like `tools/payments/common.py`): Used to share code (clients, models, etc.) among components in the same subdirectory. 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 | ## Configuration (`golf.json`) 129 | 130 | The `golf.json` file is the heart of your Golf project configuration. Here's what each field controls: 131 | 132 | ```jsonc 133 | { 134 | "name": "{{project_name}}", // Your MCP server name (required) 135 | "description": "A Golf project", // Brief description of your server's purpose 136 | "host": "127.0.0.1", // Server host - use "0.0.0.0" to listen on all interfaces 137 | "port": 3000, // Server port - any available port number 138 | "transport": "sse", // Communication protocol: 139 | // - "sse": Server-Sent Events (recommended for web clients) 140 | // - "streamable-http": HTTP with streaming support 141 | // - "stdio": Standard I/O (for CLI integration) 142 | 143 | // Health Check Configuration (optional) 144 | "health_check_enabled": false, // Enable health check endpoint for Kubernetes/load balancers 145 | "health_check_path": "/health", // HTTP path for health check endpoint 146 | "health_check_response": "OK", // Response text returned by health check 147 | 148 | // OpenTelemetry Configuration (optional) 149 | "opentelemetry_enabled": false, // Enable distributed tracing 150 | "opentelemetry_default_exporter": "console" // Default exporter if OTEL_TRACES_EXPORTER not set 151 | // Options: "console", "otlp_http" 152 | } 153 | ``` 154 | 155 | ### Key Configuration Options: 156 | 157 | - **`name`**: The identifier for your MCP server. This will be shown to clients connecting to your server. 158 | - **`transport`**: Choose based on your client needs: 159 | - `"sse"` is ideal for web-based clients and real-time communication 160 | - `"streamable-http"` provides HTTP streaming for traditional API clients 161 | - `"stdio"` enables integration with command-line tools and scripts 162 | - **`host` & `port`**: Control where your server listens. Use `"127.0.0.1"` for local development or `"0.0.0.0"` to accept external connections. 163 | - **`health_check_enabled`**: When true, enables a health check endpoint for Kubernetes readiness/liveness probes and load balancers 164 | - **`health_check_path`**: Customizable path for the health check endpoint (defaults to "/health") 165 | - **`health_check_response`**: Customizable response text for successful health checks (defaults to "OK") 166 | - **`opentelemetry_enabled`**: When true, enables distributed tracing for debugging and monitoring your MCP server 167 | - **`opentelemetry_default_exporter`**: Sets the default trace exporter. Can be overridden by the `OTEL_TRACES_EXPORTER` environment variable 168 | 169 | ## Features 170 | 171 | ### 🏥 Health Check Support 172 | 173 | Golf includes built-in health check endpoint support for production deployments. When enabled, it automatically adds a custom HTTP route that can be used by: 174 | - Kubernetes readiness and liveness probes 175 | - Load balancers and reverse proxies 176 | - Monitoring systems 177 | - Container orchestration platforms 178 | 179 | #### Configuration 180 | 181 | Enable health checks in your `golf.json`: 182 | ```json 183 | { 184 | "health_check_enabled": true, 185 | "health_check_path": "/health", 186 | "health_check_response": "Service is healthy" 187 | } 188 | ``` 189 | 190 | The generated server will include a route like: 191 | ```python 192 | @mcp.custom_route('/health', methods=["GET"]) 193 | async def health_check(request: Request) -> PlainTextResponse: 194 | """Health check endpoint for Kubernetes and load balancers.""" 195 | return PlainTextResponse("Service is healthy") 196 | ``` 197 | 198 | ### 🔍 OpenTelemetry Support 199 | 200 | Golf includes built-in OpenTelemetry instrumentation for distributed tracing. When enabled, it automatically traces: 201 | - Tool executions with arguments and results 202 | - Resource reads and template expansions 203 | - Prompt generations 204 | - HTTP requests and sessions 205 | 206 | #### Configuration 207 | 208 | Enable OpenTelemetry in your `golf.json`: 209 | ```json 210 | { 211 | "opentelemetry_enabled": true, 212 | "opentelemetry_default_exporter": "otlp_http" 213 | } 214 | ``` 215 | 216 | Then configure via environment variables: 217 | ```bash 218 | # For OTLP HTTP exporter (e.g., Jaeger, Grafana Tempo) 219 | OTEL_TRACES_EXPORTER=otlp_http 220 | OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces 221 | OTEL_SERVICE_NAME=my-golf-server # Optional, defaults to project name 222 | 223 | # For console exporter (debugging) 224 | OTEL_TRACES_EXPORTER=console 225 | ``` 226 | 227 | **Note**: When using the OTLP HTTP exporter, you must set `OTEL_EXPORTER_OTLP_ENDPOINT`. If not configured, Golf will display a warning and disable tracing to avoid errors. 228 | 229 | ## Roadmap 230 | 231 | Here are the things we are working hard on: 232 | 233 | * **`golf deploy` command for one click deployments to Vercel, Blaxel and other providers** 234 | * **Production-ready OAuth token management, to allow for persistent, encrypted token storage and client mapping** 235 | 236 | 237 | ## Privacy & Telemetry 238 | 239 | 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: 240 | 241 | - Commands run (init, build, run) 242 | - Success/failure status (no error details) 243 | - Golf version, Python version (major.minor only), and OS type 244 | - Template name (for init command only) 245 | - Build environment (dev/prod for build commands only) 246 | 247 | **No personal information, project names, code content, or error messages are ever collected.** 248 | 249 | ### Opting Out 250 | 251 | You can disable telemetry in several ways: 252 | 253 | 1. **Using the telemetry command** (recommended): 254 | ```bash 255 | golf telemetry disable 256 | ``` 257 | This saves your preference permanently. To re-enable: 258 | ```bash 259 | golf telemetry enable 260 | ``` 261 | 262 | 2. **During any command**: Add `--no-telemetry` to save your preference: 263 | ```bash 264 | golf init my-project --no-telemetry 265 | ``` 266 | 267 | Your telemetry preference is stored in `~/.golf/telemetry.json` and persists across all Golf commands. 268 | 269 |
270 | Made with ❤️ in Warsaw, Poland and SF 271 |
-------------------------------------------------------------------------------- /golf-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golf-mcp/golf/44365d3fe7479c6f6f6e726e2f4e0c551e2b2eec/golf-banner.png -------------------------------------------------------------------------------- /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.1.16" 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.0.0", 32 | "pydantic>=2.11.0", 33 | "python-dotenv>=1.1.0", 34 | "black>=24.10.0", 35 | "pyjwt>=2.0.0", 36 | "httpx>=0.28.1", 37 | "posthog>=4.1.0" 38 | ] 39 | 40 | [project.optional-dependencies] 41 | telemetry = [ 42 | "opentelemetry-api>=1.33.1", 43 | "opentelemetry-sdk>=1.33.1", 44 | "opentelemetry-instrumentation-asgi>=0.40b0", 45 | "opentelemetry-exporter-otlp-proto-http>=0.40b0", 46 | "wrapt>=1.17.0" 47 | ] 48 | 49 | [project.scripts] 50 | golf = "golf.cli.main:app" 51 | 52 | [project.urls] 53 | "Homepage" = "https://golf.dev" 54 | "Repository" = "https://github.com/golf-mcp/golf" 55 | 56 | [tool.setuptools] 57 | package-dir = {"" = "src"} 58 | [tool.setuptools.packages.find] 59 | where = ["src"] 60 | include = ["golf*"] 61 | exclude = ["golf.tests*"] # Example: if you have tests inside src/golf/tests 62 | [tool.setuptools.package-data] 63 | golf = ["examples/**/*"] 64 | 65 | [tool.poetry] 66 | name = "golf-mcp" 67 | version = "0.1.16" 68 | description = "Framework for building MCP servers with zero boilerplate" 69 | authors = ["Antoni Gmitruk "] 70 | license = "Apache-2.0" 71 | readme = "README.md" 72 | repository = "https://github.com/golf-mcp/golf" 73 | homepage = "https://golf.dev" 74 | packages = [{include = "golf", from = "src"}] 75 | classifiers = [ 76 | "Development Status :: 3 - Alpha", 77 | "Intended Audience :: Developers", 78 | "Topic :: Software Development :: Libraries :: Python Modules", 79 | "Programming Language :: Python :: 3", 80 | "Programming Language :: Python :: 3.8", 81 | "Programming Language :: Python :: 3.9", 82 | "Programming Language :: Python :: 3.10", 83 | "Programming Language :: Python :: 3.11", 84 | "Programming Language :: Python :: 3.12" 85 | ] 86 | 87 | [tool.poetry.dependencies] 88 | python = ">=3.8" # Match requires-python 89 | fastmcp = ">=2.0.0" 90 | typer = {extras = ["all"], version = ">=0.15.4"} 91 | pydantic = ">=2.11.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 | # Optional dependencies listed separately for poetry if desired, or managed by extras 98 | opentelemetry-api = {version = ">=1.33.1", optional = true} 99 | opentelemetry-sdk = {version = ">=1.33.1", optional = true} 100 | opentelemetry-instrumentation-asgi = {version = ">=0.40b0", optional = true} 101 | opentelemetry-exporter-otlp-proto-http = {version = ">=0.40b0", optional = true} 102 | wrapt = {version = ">=1.17.0", optional = true} 103 | 104 | [tool.poetry.extras] 105 | telemetry = ["opentelemetry-api", "opentelemetry-sdk", "opentelemetry-instrumentation-asgi", "opentelemetry-exporter-otlp-proto-http", "wrapt"] 106 | 107 | [tool.poetry.group.dev.dependencies] 108 | pytest = "^7.4.0" 109 | pytest-asyncio = "^0.23.0" 110 | ruff = "^0.1.0" 111 | mypy = "^1.6.0" 112 | pytest-cov = "^4.1.0" 113 | 114 | [tool.black] 115 | line-length = 88 116 | 117 | [tool.ruff] 118 | line-length = 88 119 | target-version = "py311" 120 | 121 | [tool.ruff.lint] 122 | select = ["E", "F", "B", "C4", "C90", "UP", "N", "ANN", "SIM", "TID"] 123 | ignore = ["ANN401"] 124 | 125 | [tool.ruff.format] 126 | # Use Black-compatible formatting 127 | quote-style = "double" 128 | indent-style = "space" 129 | skip-magic-trailing-comma = false 130 | line-ending = "auto" 131 | 132 | [tool.mypy] 133 | python_version = "3.11" 134 | disallow_untyped_defs = true 135 | disallow_incomplete_defs = true 136 | check_untyped_defs = true 137 | disallow_untyped_decorators = true 138 | no_implicit_optional = true 139 | strict_optional = true 140 | warn_redundant_casts = true 141 | warn_unused_ignores = true 142 | warn_return_any = true 143 | warn_unused_configs = true 144 | 145 | [tool.pytest.ini_options] 146 | testpaths = ["tests"] 147 | python_files = ["test_*.py", "*_test.py"] 148 | python_classes = ["Test*"] 149 | python_functions = ["test_*"] 150 | asyncio_mode = "auto" 151 | addopts = [ 152 | "-v", 153 | "--strict-markers", 154 | "--tb=short", 155 | "--cov=golf", 156 | "--cov-report=term-missing", 157 | "--cov-report=html", 158 | ] 159 | markers = [ 160 | "slow: marks tests as slow (deselect with '-m \"not slow\"')", 161 | "integration: marks tests as integration tests", 162 | ] -------------------------------------------------------------------------------- /src/golf/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.16" 2 | -------------------------------------------------------------------------------- /src/golf/auth/__init__.py: -------------------------------------------------------------------------------- 1 | """Authentication module for GolfMCP servers. 2 | 3 | This module provides a simple API for configuring OAuth authentication 4 | for GolfMCP servers. Users can configure authentication in their pre_build.py 5 | file without needing to understand the complexities of the MCP SDK. 6 | """ 7 | 8 | from typing import List, Optional, Tuple 9 | 10 | from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions 11 | 12 | from .api_key import configure_api_key, get_api_key_config, is_api_key_configured 13 | from .helpers import ( 14 | debug_api_key_context, 15 | extract_token_from_header, 16 | get_access_token, 17 | get_api_key, 18 | get_provider_token, 19 | set_api_key, 20 | ) 21 | from .oauth import GolfOAuthProvider, create_callback_handler 22 | from .provider import ProviderConfig 23 | 24 | 25 | class AuthConfig: 26 | """Configuration for OAuth authentication in GolfMCP.""" 27 | 28 | def __init__( 29 | self, 30 | provider_config: ProviderConfig, 31 | required_scopes: list[str], 32 | callback_path: str = "/auth/callback", 33 | login_path: str = "/login", 34 | error_path: str = "/auth-error", 35 | ) -> None: 36 | """Initialize authentication configuration. 37 | 38 | Args: 39 | provider_config: Configuration for the OAuth provider 40 | required_scopes: Scopes required for all authenticated requests 41 | callback_path: Path for the OAuth callback 42 | login_path: Path for the login redirect 43 | error_path: Path for displaying authentication errors 44 | """ 45 | self.provider_config = provider_config 46 | self.required_scopes = required_scopes 47 | self.callback_path = callback_path 48 | self.login_path = login_path 49 | self.error_path = error_path 50 | 51 | # Create the OAuth provider 52 | self.provider = GolfOAuthProvider(provider_config) 53 | 54 | # Create auth settings for FastMCP 55 | self.auth_settings = AuthSettings( 56 | issuer_url=provider_config.issuer_url or "http://localhost:3000", 57 | client_registration_options=ClientRegistrationOptions( 58 | enabled=True, 59 | valid_scopes=provider_config.scopes, 60 | default_scopes=provider_config.scopes, 61 | ), 62 | required_scopes=required_scopes or provider_config.scopes, 63 | ) 64 | 65 | 66 | # Global state for the build process 67 | _auth_config: AuthConfig | None = None 68 | 69 | 70 | def configure_auth( 71 | provider_config=None, 72 | provider=None, 73 | required_scopes: list[str] | None = None, 74 | callback_path: str = "/auth/callback", 75 | ) -> None: 76 | """Configure authentication for a GolfMCP server. 77 | 78 | This function should be called in pre_build.py to set up authentication. 79 | 80 | Args: 81 | provider_config: Configuration for the OAuth provider (new parameter name) 82 | provider: Configuration for the OAuth provider (old parameter name, deprecated) 83 | required_scopes: Scopes required for authentication 84 | callback_path: Path for the OAuth callback 85 | public_paths: List of paths that don't require authentication (deprecated, no longer used) 86 | """ 87 | global _auth_config 88 | 89 | # Handle backward compatibility with old parameter name 90 | if provider_config is None and provider is not None: 91 | provider_config = provider 92 | elif provider_config is None and provider is None: 93 | raise ValueError("Either provider_config or provider must be provided") 94 | 95 | _auth_config = AuthConfig( 96 | provider_config=provider_config, 97 | required_scopes=required_scopes or provider_config.scopes, 98 | callback_path=callback_path, 99 | ) 100 | 101 | 102 | def get_auth_config() -> tuple[ProviderConfig | None, list[str]]: 103 | """Get the current authentication configuration. 104 | 105 | Returns: 106 | Tuple of (provider_config, required_scopes) 107 | """ 108 | if _auth_config: 109 | return _auth_config.provider_config, _auth_config.required_scopes 110 | return None, [] 111 | 112 | 113 | def create_auth_provider() -> GolfOAuthProvider | None: 114 | """Create an OAuth provider from the configured provider settings. 115 | 116 | Returns: 117 | GolfOAuthProvider instance or None if not configured 118 | """ 119 | if not _auth_config: 120 | return None 121 | 122 | return _auth_config.provider 123 | -------------------------------------------------------------------------------- /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( 15 | "X-API-Key", description="Name of the header containing the API key" 16 | ) 17 | header_prefix: str = Field( 18 | "", 19 | description="Optional prefix to strip from the header value (e.g., 'Bearer ')", 20 | ) 21 | required: bool = Field( 22 | True, description="Whether API key is required for all requests" 23 | ) 24 | 25 | 26 | # Global configuration storage 27 | _api_key_config: ApiKeyConfig | None = None 28 | 29 | 30 | def configure_api_key( 31 | header_name: str = "X-API-Key", header_prefix: str = "", required: bool = True 32 | ) -> None: 33 | """Configure API key extraction from request headers. 34 | 35 | This function should be called in pre_build.py to set up API key handling. 36 | 37 | Args: 38 | header_name: Name of the header containing the API key (default: "X-API-Key") 39 | header_prefix: Optional prefix to strip from the header value (e.g., "Bearer ") 40 | required: Whether API key is required for all requests (default: True) 41 | 42 | Example: 43 | # In pre_build.py 44 | from golf.auth.api_key import configure_api_key 45 | 46 | # Require API key for all requests 47 | configure_api_key( 48 | header_name="Authorization", 49 | header_prefix="Bearer ", 50 | required=True 51 | ) 52 | 53 | # Or make API key optional (pass-through mode) 54 | configure_api_key( 55 | header_name="Authorization", 56 | header_prefix="Bearer ", 57 | required=False 58 | ) 59 | """ 60 | global _api_key_config 61 | _api_key_config = ApiKeyConfig( 62 | header_name=header_name, header_prefix=header_prefix, required=required 63 | ) 64 | 65 | 66 | def get_api_key_config() -> ApiKeyConfig | None: 67 | """Get the current API key configuration. 68 | 69 | Returns: 70 | The API key configuration if set, None otherwise 71 | """ 72 | return _api_key_config 73 | 74 | 75 | def is_api_key_configured() -> bool: 76 | """Check if API key authentication is configured. 77 | 78 | Returns: 79 | True if API key authentication is configured, False otherwise 80 | """ 81 | return _api_key_config is not None 82 | -------------------------------------------------------------------------------- /src/golf/auth/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for working with authentication in MCP context.""" 2 | 3 | from contextvars import ContextVar 4 | from typing import Any 5 | 6 | # Re-export get_access_token from the MCP SDK 7 | from mcp.server.auth.middleware.auth_context import get_access_token 8 | 9 | from .oauth import GolfOAuthProvider 10 | 11 | # Context variable to store the active OAuth provider 12 | _active_golf_oauth_provider: GolfOAuthProvider | None = None 13 | 14 | # Context variable to store the current request's API key 15 | _current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None) 16 | 17 | 18 | def _set_active_golf_oauth_provider(provider: GolfOAuthProvider) -> None: 19 | """ 20 | Sets the active GolfOAuthProvider instance. 21 | Should only be called once during server startup. 22 | """ 23 | global _active_golf_oauth_provider 24 | _active_golf_oauth_provider = provider 25 | 26 | 27 | def get_provider_token() -> str | None: 28 | """ 29 | Get a provider token (e.g., GitHub token) associated with the current 30 | MCP session's access token. 31 | 32 | This relies on _set_active_golf_oauth_provider being called at server startup. 33 | """ 34 | mcp_access_token = get_access_token() # From MCP SDK, uses its own ContextVar 35 | if not mcp_access_token: 36 | # No active MCP session token. 37 | return None 38 | 39 | provider = _active_golf_oauth_provider 40 | if not provider: 41 | return None 42 | 43 | if not hasattr(provider, "get_provider_token"): 44 | return None 45 | 46 | # Call the get_provider_token method on the actual GolfOAuthProvider instance 47 | return provider.get_provider_token(mcp_access_token.token) 48 | 49 | 50 | def extract_token_from_header(auth_header: str) -> str | None: 51 | """Extract bearer token from Authorization header. 52 | 53 | Args: 54 | auth_header: Authorization header value 55 | 56 | Returns: 57 | Bearer token or None if not present/valid 58 | """ 59 | if not auth_header: 60 | return None 61 | 62 | parts = auth_header.split() 63 | if len(parts) != 2 or parts[0].lower() != "bearer": 64 | return None 65 | 66 | return parts[1] 67 | 68 | 69 | def set_api_key(api_key: str | None) -> None: 70 | """Set the API key for the current request context. 71 | 72 | This is an internal function used by the middleware. 73 | 74 | Args: 75 | api_key: The API key to store in the context 76 | """ 77 | _current_api_key.set(api_key) 78 | 79 | 80 | def get_api_key() -> str | None: 81 | """Get the API key from the current request context. 82 | 83 | This function should be used in tools to retrieve the API key 84 | that was sent in the request headers. 85 | 86 | Returns: 87 | The API key if available, None otherwise 88 | 89 | Example: 90 | # In a tool file 91 | from golf.auth import get_api_key 92 | 93 | async def call_api(): 94 | api_key = get_api_key() 95 | if not api_key: 96 | return {"error": "No API key provided"} 97 | 98 | # Use the API key in your request 99 | headers = {"Authorization": f"Bearer {api_key}"} 100 | ... 101 | """ 102 | # Try to get directly from HTTP request if available (FastMCP pattern) 103 | try: 104 | # This follows the FastMCP pattern for accessing HTTP requests 105 | from fastmcp.server.dependencies import get_http_request 106 | 107 | request = get_http_request() 108 | 109 | if request and hasattr(request, "state") and hasattr(request.state, "api_key"): 110 | api_key = request.state.api_key 111 | return api_key 112 | 113 | # Get the API key configuration 114 | from golf.auth.api_key import get_api_key_config 115 | 116 | api_key_config = get_api_key_config() 117 | 118 | if api_key_config and request: 119 | # Extract API key from headers 120 | header_name = api_key_config.header_name 121 | header_prefix = api_key_config.header_prefix 122 | 123 | # Case-insensitive header lookup 124 | api_key = None 125 | for k, v in request.headers.items(): 126 | if k.lower() == header_name.lower(): 127 | api_key = v 128 | break 129 | 130 | # Strip prefix if configured 131 | if api_key and header_prefix and api_key.startswith(header_prefix): 132 | api_key = api_key[len(header_prefix) :] 133 | 134 | if api_key: 135 | return api_key 136 | except (ImportError, RuntimeError): 137 | # FastMCP not available or not in HTTP context 138 | pass 139 | except Exception: 140 | pass 141 | 142 | # Final fallback: environment variable (for development/testing) 143 | import os 144 | 145 | env_api_key = os.environ.get("API_KEY") 146 | if env_api_key: 147 | return env_api_key 148 | 149 | return None 150 | 151 | 152 | def get_api_key_from_request(request) -> str | None: 153 | """Get the API key from a specific request object. 154 | 155 | This is useful when you have direct access to the request object. 156 | 157 | Args: 158 | request: The Starlette Request object 159 | 160 | Returns: 161 | The API key if available, None otherwise 162 | """ 163 | # Check request state first (set by our middleware) 164 | if hasattr(request, "state") and hasattr(request.state, "api_key"): 165 | return request.state.api_key 166 | 167 | # Fall back to context variable 168 | return _current_api_key.get() 169 | 170 | 171 | def debug_api_key_context() -> dict[str, Any]: 172 | """Debug function to inspect API key context. 173 | 174 | Returns a dictionary with debugging information about the current 175 | API key context. Useful for troubleshooting authentication issues. 176 | 177 | Returns: 178 | Dictionary with debug information 179 | """ 180 | import asyncio 181 | import os 182 | import sys 183 | 184 | debug_info = { 185 | "context_var_value": _current_api_key.get(), 186 | "has_async_task": False, 187 | "task_id": None, 188 | "main_module_has_storage": False, 189 | "main_module_has_context": False, 190 | "request_id_from_context": None, 191 | "env_vars": { 192 | "API_KEY": bool(os.environ.get("API_KEY")), 193 | "GOLF_API_KEY_DEBUG": os.environ.get("GOLF_API_KEY_DEBUG", "false"), 194 | }, 195 | } 196 | 197 | try: 198 | task = asyncio.current_task() 199 | if task: 200 | debug_info["has_async_task"] = True 201 | debug_info["task_id"] = id(task) 202 | except: 203 | pass 204 | 205 | try: 206 | main_module = sys.modules.get("__main__") 207 | if main_module: 208 | debug_info["main_module_has_storage"] = hasattr( 209 | main_module, "api_key_storage" 210 | ) 211 | debug_info["main_module_has_context"] = hasattr( 212 | main_module, "request_id_context" 213 | ) 214 | 215 | if hasattr(main_module, "request_id_context"): 216 | request_id_context = main_module.request_id_context 217 | debug_info["request_id_from_context"] = request_id_context.get() 218 | except: 219 | pass 220 | 221 | return debug_info 222 | -------------------------------------------------------------------------------- /src/golf/auth/provider.py: -------------------------------------------------------------------------------- 1 | """OAuth provider configuration for GolfMCP authentication. 2 | 3 | This module defines the ProviderConfig class used to configure 4 | OAuth authentication for GolfMCP servers. 5 | """ 6 | 7 | from typing import Any 8 | 9 | from pydantic import BaseModel, Field, field_validator 10 | 11 | 12 | class ProviderConfig(BaseModel): 13 | """Configuration for an OAuth2 provider. 14 | 15 | This class defines the configuration for an OAuth2 provider, 16 | including the endpoints, credentials, and other settings needed 17 | to authenticate with the provider. 18 | """ 19 | 20 | # Provider identification 21 | provider: str = Field( 22 | ..., description="Provider type (e.g., 'github', 'google', 'custom')" 23 | ) 24 | 25 | # OAuth credentials - names of environment variables to read at runtime 26 | client_id_env_var: str = Field( 27 | ..., description="Name of environment variable for Client ID" 28 | ) 29 | client_secret_env_var: str = Field( 30 | ..., description="Name of environment variable for Client Secret" 31 | ) 32 | 33 | # These fields will store the actual values read at runtime in dist/server.py 34 | # They are made optional here as they are resolved in the generated code. 35 | client_id: str | None = Field( 36 | None, description="OAuth client ID (resolved at runtime)" 37 | ) 38 | client_secret: str | None = Field( 39 | None, description="OAuth client secret (resolved at runtime)" 40 | ) 41 | 42 | # OAuth endpoints (can be baked in) 43 | authorize_url: str = Field(..., description="Authorization endpoint URL") 44 | token_url: str = Field(..., description="Token endpoint URL") 45 | userinfo_url: str | None = Field( 46 | None, description="User info endpoint URL (for OIDC providers)" 47 | ) 48 | 49 | jwks_uri: str | None = Field( 50 | None, description="JSON Web Key Set URI (for token validation)" 51 | ) 52 | 53 | scopes: list[str] = Field( 54 | default_factory=list, description="OAuth scopes to request from the provider" 55 | ) 56 | 57 | issuer_url: str | None = Field( 58 | None, 59 | description="OIDC issuer URL for discovery (if using OIDC) - will be overridden by runtime value in server.py", 60 | ) 61 | 62 | callback_path: str = Field( 63 | "/auth/callback", 64 | description="Path on this server where the IdP should redirect after authentication", 65 | ) 66 | 67 | # JWT configuration 68 | jwt_secret_env_var: str = Field( 69 | ..., description="Name of environment variable for JWT Secret" 70 | ) 71 | jwt_secret: str | None = Field( 72 | None, description="Secret key for signing JWT tokens (resolved at runtime)" 73 | ) 74 | token_expiration: int = Field( 75 | 3600, description="JWT token expiration time in seconds", ge=60, le=86400 76 | ) 77 | 78 | settings: dict[str, Any] = Field( 79 | default_factory=dict, description="Additional provider-specific settings" 80 | ) 81 | 82 | @field_validator("provider") 83 | @classmethod 84 | def validate_provider(cls, value: str) -> str: 85 | """Validate the provider type. 86 | 87 | Ensures the provider type is a valid, supported provider. 88 | 89 | Args: 90 | value: The provider type 91 | 92 | Returns: 93 | The validated provider type 94 | 95 | Raises: 96 | ValueError: If the provider type is not supported 97 | """ 98 | known_providers = {"custom", "github", "google", "jwks"} 99 | 100 | if value not in known_providers and not value.startswith("custom:"): 101 | raise ValueError( 102 | f"Unknown provider: '{value}'. Must be one of {known_providers} " 103 | "or start with 'custom:'" 104 | ) 105 | return value 106 | 107 | def get_provider_name(self) -> str: 108 | """Get a clean provider name for display purposes. 109 | 110 | Returns: 111 | A human-readable provider name 112 | """ 113 | if self.provider.startswith("custom:"): 114 | return self.provider[7:] # Remove 'custom:' prefix 115 | return self.provider.capitalize() 116 | -------------------------------------------------------------------------------- /src/golf/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """CLI package for the GolfMCP framework.""" 2 | -------------------------------------------------------------------------------- /src/golf/cli/main.py: -------------------------------------------------------------------------------- 1 | """CLI entry points for GolfMCP.""" 2 | 3 | import atexit 4 | import os 5 | from pathlib import Path 6 | 7 | import typer 8 | from rich.console import Console 9 | from rich.panel import Panel 10 | 11 | from golf import __version__ 12 | from golf.core.config import find_project_root, load_settings 13 | from golf.core.telemetry import ( 14 | is_telemetry_enabled, 15 | set_telemetry_enabled, 16 | shutdown, 17 | track_event, 18 | track_detailed_error, 19 | ) 20 | 21 | # Create console for rich output 22 | console = Console() 23 | 24 | # Create the typer app instance 25 | app = typer.Typer( 26 | name="golf", 27 | help="GolfMCP: A Pythonic framework for building MCP servers with zero boilerplate", 28 | add_completion=False, 29 | ) 30 | 31 | # Register telemetry shutdown on exit 32 | atexit.register(shutdown) 33 | 34 | 35 | def _version_callback(value: bool) -> None: 36 | """Print version and exit if --version flag is used.""" 37 | if value: 38 | console.print(f"GolfMCP v{__version__}") 39 | raise typer.Exit() 40 | 41 | 42 | @app.callback() 43 | def callback( 44 | version: bool = typer.Option( 45 | None, 46 | "--version", 47 | "-V", 48 | help="Show the version and exit.", 49 | callback=_version_callback, 50 | is_eager=True, 51 | ), 52 | verbose: bool = typer.Option( 53 | False, "--verbose", "-v", help="Increase verbosity of output." 54 | ), 55 | no_telemetry: bool = typer.Option( 56 | False, 57 | "--no-telemetry", 58 | help="Disable telemetry collection (persists for future commands).", 59 | ), 60 | test: bool = typer.Option( 61 | False, 62 | "--test", 63 | hidden=True, 64 | help="Run in test mode (disables telemetry for this execution only).", 65 | ), 66 | ) -> None: 67 | """GolfMCP: A Pythonic framework for building MCP servers with zero boilerplate.""" 68 | # Set verbosity in environment for other components to access 69 | if verbose: 70 | os.environ["GOLF_VERBOSE"] = "1" 71 | 72 | # Set test mode if flag is used (temporary, just for this execution) 73 | if test: 74 | set_telemetry_enabled(False, persist=False) 75 | os.environ["GOLF_TEST_MODE"] = "1" 76 | 77 | # Set telemetry preference if flag is used (permanent) 78 | if no_telemetry: 79 | set_telemetry_enabled(False, persist=True) 80 | console.print( 81 | "[dim]Telemetry has been disabled. You can re-enable it with: golf telemetry enable[/dim]" 82 | ) 83 | 84 | 85 | @app.command() 86 | def init( 87 | project_name: str = typer.Argument(..., help="Name of the project to create"), 88 | output_dir: Path | None = typer.Option( 89 | None, "--output-dir", "-o", help="Directory to create the project in" 90 | ), 91 | template: str = typer.Option( 92 | "basic", "--template", "-t", help="Template to use (basic or api_key)" 93 | ), 94 | ) -> None: 95 | """Initialize a new GolfMCP project. 96 | 97 | Creates a new directory with the project scaffold, including 98 | examples for tools, resources, and prompts. 99 | """ 100 | # Import here to avoid circular imports 101 | from golf.commands.init import initialize_project 102 | 103 | # Use the current directory if no output directory is specified 104 | if output_dir is None: 105 | output_dir = Path.cwd() / project_name 106 | 107 | # Execute the initialization command (it handles its own tracking) 108 | initialize_project( 109 | project_name=project_name, output_dir=output_dir, template=template 110 | ) 111 | 112 | 113 | # Create a build group with subcommands 114 | build_app = typer.Typer(help="Build a standalone FastMCP application") 115 | app.add_typer(build_app, name="build") 116 | 117 | 118 | @build_app.command("dev") 119 | def build_dev( 120 | output_dir: str | None = typer.Option( 121 | None, "--output-dir", "-o", help="Directory to output the built project" 122 | ), 123 | ) -> None: 124 | """Build a development version with app environment variables copied. 125 | 126 | Golf credentials (GOLF_*) are always loaded from .env for build operations. 127 | All environment variables are copied to the built project for development. 128 | """ 129 | # Find project root directory 130 | project_root, config_path = find_project_root() 131 | 132 | if not project_root: 133 | console.print( 134 | "[bold red]Error: No GolfMCP project found in the current directory or any parent directory.[/bold red]" 135 | ) 136 | console.print("Run 'golf init ' to create a new project.") 137 | track_event( 138 | "cli_build_failed", 139 | { 140 | "success": False, 141 | "environment": "dev", 142 | "error_type": "NoProjectFound", 143 | "error_message": "No GolfMCP project found", 144 | }, 145 | ) 146 | raise typer.Exit(code=1) 147 | 148 | # Load settings from the found project 149 | settings = load_settings(project_root) 150 | 151 | # Set default output directory if not specified 152 | output_dir = project_root / "dist" if output_dir is None else Path(output_dir) 153 | 154 | try: 155 | # Build the project with environment variables copied 156 | from golf.commands.build import build_project 157 | 158 | build_project( 159 | project_root, settings, output_dir, build_env="dev", copy_env=True 160 | ) 161 | # Track successful build with environment 162 | track_event("cli_build_success", {"success": True, "environment": "dev"}) 163 | except Exception as e: 164 | track_detailed_error( 165 | "cli_build_failed", 166 | e, 167 | context="Development build with environment variables", 168 | operation="build_dev", 169 | additional_props={"environment": "dev", "copy_env": True}, 170 | ) 171 | raise 172 | 173 | 174 | @build_app.command("prod") 175 | def build_prod( 176 | output_dir: str | None = typer.Option( 177 | None, "--output-dir", "-o", help="Directory to output the built project" 178 | ), 179 | ) -> None: 180 | """Build a production version for deployment. 181 | 182 | Golf credentials (GOLF_*) are always loaded from .env for build operations 183 | (platform registration, resource updates). App environment variables are 184 | NOT copied for security - provide them in your deployment environment. 185 | 186 | Your production deployment must include GOLF_* vars for runtime telemetry. 187 | """ 188 | # Find project root directory 189 | project_root, config_path = find_project_root() 190 | 191 | if not project_root: 192 | console.print( 193 | "[bold red]Error: No GolfMCP project found in the current directory or any parent directory.[/bold red]" 194 | ) 195 | console.print("Run 'golf init ' to create a new project.") 196 | track_event( 197 | "cli_build_failed", 198 | { 199 | "success": False, 200 | "environment": "prod", 201 | "error_type": "NoProjectFound", 202 | "error_message": "No GolfMCP project found", 203 | }, 204 | ) 205 | raise typer.Exit(code=1) 206 | 207 | # Load settings from the found project 208 | settings = load_settings(project_root) 209 | 210 | # Set default output directory if not specified 211 | output_dir = project_root / "dist" if output_dir is None else Path(output_dir) 212 | 213 | try: 214 | # Build the project without copying environment variables 215 | from golf.commands.build import build_project 216 | 217 | build_project( 218 | project_root, settings, output_dir, build_env="prod", copy_env=False 219 | ) 220 | # Track successful build with environment 221 | track_event("cli_build_success", {"success": True, "environment": "prod"}) 222 | except Exception as e: 223 | track_detailed_error( 224 | "cli_build_failed", 225 | e, 226 | context="Production build without environment variables", 227 | operation="build_prod", 228 | additional_props={"environment": "prod", "copy_env": False}, 229 | ) 230 | raise 231 | 232 | 233 | @app.command() 234 | def run( 235 | dist_dir: str | None = typer.Option( 236 | None, "--dist-dir", "-d", help="Directory containing the built server" 237 | ), 238 | host: str | None = typer.Option( 239 | None, "--host", "-h", help="Host to bind to (overrides settings)" 240 | ), 241 | port: int | None = typer.Option( 242 | None, "--port", "-p", help="Port to bind to (overrides settings)" 243 | ), 244 | build_first: bool = typer.Option( 245 | True, "--build/--no-build", help="Build the project before running" 246 | ), 247 | ) -> None: 248 | """Run the built FastMCP server. 249 | 250 | This command runs the built server from the dist directory. 251 | By default, it will build the project first if needed. 252 | """ 253 | # Find project root directory 254 | project_root, config_path = find_project_root() 255 | 256 | if not project_root: 257 | console.print( 258 | "[bold red]Error: No GolfMCP project found in the current directory or any parent directory.[/bold red]" 259 | ) 260 | console.print("Run 'golf init ' to create a new project.") 261 | track_event( 262 | "cli_run_failed", 263 | { 264 | "success": False, 265 | "error_type": "NoProjectFound", 266 | "error_message": "No GolfMCP project found", 267 | }, 268 | ) 269 | raise typer.Exit(code=1) 270 | 271 | # Load settings from the found project 272 | settings = load_settings(project_root) 273 | 274 | # Set default dist directory if not specified 275 | dist_dir = project_root / "dist" if dist_dir is None else Path(dist_dir) 276 | 277 | # Check if dist directory exists 278 | if not dist_dir.exists(): 279 | if build_first: 280 | console.print( 281 | f"[yellow]Dist directory {dist_dir} not found. Building first...[/yellow]" 282 | ) 283 | try: 284 | # Build the project 285 | from golf.commands.build import build_project 286 | 287 | build_project(project_root, settings, dist_dir) 288 | except Exception as e: 289 | console.print(f"[bold red]Error building project:[/bold red] {str(e)}") 290 | track_detailed_error( 291 | "cli_run_failed", 292 | e, 293 | context="Auto-build before running server", 294 | operation="auto_build_before_run", 295 | additional_props={"auto_build": True}, 296 | ) 297 | raise 298 | else: 299 | console.print( 300 | f"[bold red]Error: Dist directory {dist_dir} not found.[/bold red]" 301 | ) 302 | console.print( 303 | "Run 'golf build' first or use --build to build automatically." 304 | ) 305 | track_event( 306 | "cli_run_failed", 307 | { 308 | "success": False, 309 | "error_type": "DistNotFound", 310 | "error_message": "Dist directory not found", 311 | }, 312 | ) 313 | raise typer.Exit(code=1) 314 | 315 | try: 316 | # Import and run the server 317 | from golf.commands.run import run_server 318 | 319 | return_code = run_server( 320 | project_path=project_root, 321 | settings=settings, 322 | dist_dir=dist_dir, 323 | host=host, 324 | port=port, 325 | ) 326 | 327 | # Track based on return code with better categorization 328 | if return_code == 0: 329 | track_event("cli_run_success", {"success": True}) 330 | elif return_code in [130, 143, 137, 2]: 331 | # Intentional shutdowns (not errors): 332 | # 130: Ctrl+C (SIGINT) 333 | # 143: SIGTERM (graceful shutdown, e.g., Kubernetes, Docker) 334 | # 137: SIGKILL (forced shutdown) 335 | # 2: General interrupt/graceful shutdown 336 | shutdown_type = { 337 | 130: "UserInterrupt", 338 | 143: "GracefulShutdown", 339 | 137: "ForcedShutdown", 340 | 2: "Interrupt", 341 | }.get(return_code, "GracefulShutdown") 342 | 343 | track_event( 344 | "cli_run_shutdown", 345 | { 346 | "success": True, # Not an error 347 | "shutdown_type": shutdown_type, 348 | "exit_code": return_code, 349 | }, 350 | ) 351 | else: 352 | # Actual errors (unexpected exit codes) 353 | track_event( 354 | "cli_run_failed", 355 | { 356 | "success": False, 357 | "error_type": "UnexpectedExit", 358 | "error_message": f"Server process exited unexpectedly with code {return_code}", 359 | "exit_code": return_code, 360 | "operation": "server_process_execution", 361 | "context": "Server process terminated with unexpected exit code", 362 | }, 363 | ) 364 | 365 | # Exit with the same code as the server 366 | if return_code != 0: 367 | raise typer.Exit(code=return_code) 368 | except Exception as e: 369 | track_detailed_error( 370 | "cli_run_failed", 371 | e, 372 | context="Server execution or startup failure", 373 | operation="run_server_execution", 374 | additional_props={"has_dist_dir": dist_dir.exists() if dist_dir else False}, 375 | ) 376 | raise 377 | 378 | 379 | # Add telemetry command group 380 | @app.command() 381 | def telemetry( 382 | action: str = typer.Argument(..., help="Action to perform: 'enable' or 'disable'"), 383 | ) -> None: 384 | """Manage telemetry settings.""" 385 | if action.lower() == "enable": 386 | set_telemetry_enabled(True, persist=True) 387 | console.print( 388 | "[green]✓[/green] Telemetry enabled. Thank you for helping improve Golf!" 389 | ) 390 | elif action.lower() == "disable": 391 | set_telemetry_enabled(False, persist=True) 392 | console.print( 393 | "[yellow]Telemetry disabled.[/yellow] You can re-enable it anytime with: golf telemetry enable" 394 | ) 395 | else: 396 | console.print( 397 | f"[red]Unknown action '{action}'. Use 'enable' or 'disable'.[/red]" 398 | ) 399 | raise typer.Exit(code=1) 400 | 401 | 402 | if __name__ == "__main__": 403 | # Show welcome banner when run directly 404 | console.print( 405 | Panel.fit( 406 | f"[bold green]GolfMCP[/bold green] v{__version__}\n" 407 | "[dim]A Pythonic framework for building MCP servers[/dim]", 408 | border_style="green", 409 | ) 410 | ) 411 | 412 | # Add telemetry notice if enabled 413 | if is_telemetry_enabled(): 414 | console.print( 415 | "[dim]📊 Anonymous usage data is collected to improve Golf. " 416 | "Disable with: golf telemetry disable[/dim]\n" 417 | ) 418 | 419 | # Run the CLI app 420 | app() 421 | -------------------------------------------------------------------------------- /src/golf/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """GolfMCP command implementations.""" 2 | 3 | from golf.commands import build, init, run 4 | -------------------------------------------------------------------------------- /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( 36 | project_path, settings, output_dir, build_env=build_env, copy_env=copy_env 37 | ) 38 | 39 | 40 | # Add a main section to run the build_project function when this module is executed directly 41 | if __name__ == "__main__": 42 | parser = argparse.ArgumentParser( 43 | description="Build a standalone FastMCP application" 44 | ) 45 | parser.add_argument( 46 | "--project-path", 47 | "-p", 48 | type=Path, 49 | default=Path.cwd(), 50 | help="Path to the project root (default: current directory)", 51 | ) 52 | parser.add_argument( 53 | "--output-dir", 54 | "-o", 55 | type=Path, 56 | default=Path.cwd() / "dist", 57 | help="Directory to output the built project (default: ./dist)", 58 | ) 59 | parser.add_argument( 60 | "--build-env", 61 | type=str, 62 | default="prod", 63 | choices=["dev", "prod"], 64 | help="Build environment to use (default: prod)", 65 | ) 66 | parser.add_argument( 67 | "--copy-env", 68 | action="store_true", 69 | help="Copy environment variables to the built application", 70 | ) 71 | 72 | args = parser.parse_args() 73 | 74 | # Load settings from the project path 75 | settings = load_settings(args.project_path) 76 | 77 | # Execute the build 78 | build_project( 79 | args.project_path, 80 | settings, 81 | args.output_dir, 82 | build_env=args.build_env, 83 | copy_env=args.copy_env, 84 | ) 85 | -------------------------------------------------------------------------------- /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.core.telemetry import track_command, track_event 11 | 12 | console = Console() 13 | 14 | 15 | def initialize_project( 16 | project_name: str, 17 | output_dir: Path, 18 | template: str = "basic", 19 | ) -> None: 20 | """Initialize a new GolfMCP project with the specified template. 21 | 22 | Args: 23 | project_name: Name of the project 24 | output_dir: Directory where the project will be created 25 | template: Template to use (basic or api_key) 26 | """ 27 | try: 28 | # Validate template 29 | valid_templates = ("basic", "api_key") 30 | if template not in valid_templates: 31 | console.print(f"[bold red]Error:[/bold red] Unknown template '{template}'") 32 | console.print(f"Available templates: {', '.join(valid_templates)}") 33 | track_command( 34 | "init", 35 | success=False, 36 | error_type="InvalidTemplate", 37 | error_message=f"Unknown template: {template}", 38 | ) 39 | return 40 | 41 | # Check if directory exists 42 | if output_dir.exists(): 43 | if not output_dir.is_dir(): 44 | console.print( 45 | f"[bold red]Error:[/bold red] '{output_dir}' exists but is not a directory." 46 | ) 47 | track_command( 48 | "init", 49 | success=False, 50 | error_type="NotADirectory", 51 | error_message="Target exists but is not a directory", 52 | ) 53 | return 54 | 55 | # Check if directory is empty 56 | if any(output_dir.iterdir()) and not Confirm.ask( 57 | f"Directory '{output_dir}' is not empty. Continue anyway?", 58 | default=False, 59 | ): 60 | console.print("Initialization cancelled.") 61 | track_event("cli_init_cancelled", {"success": False}) 62 | return 63 | else: 64 | # Create the directory 65 | output_dir.mkdir(parents=True) 66 | 67 | # Find template directory within the installed package 68 | import golf 69 | 70 | package_init_file = Path(golf.__file__) 71 | # The 'examples' directory is now inside the 'golf' package directory 72 | # e.g. golf/examples/basic, so go up one from __init__.py to get to 'golf' 73 | template_dir = package_init_file.parent / "examples" / template 74 | 75 | if not template_dir.exists(): 76 | console.print( 77 | f"[bold red]Error:[/bold red] Could not find template '{template}'" 78 | ) 79 | track_command( 80 | "init", 81 | success=False, 82 | error_type="TemplateNotFound", 83 | error_message=f"Template directory not found: {template}", 84 | ) 85 | return 86 | 87 | # Copy template files 88 | with Progress( 89 | SpinnerColumn(), 90 | TextColumn("[bold green]Creating project structure...[/bold green]"), 91 | transient=True, 92 | ) as progress: 93 | progress.add_task("copying", total=None) 94 | 95 | # Copy directory structure 96 | _copy_template(template_dir, output_dir, project_name) 97 | 98 | # Create virtual environment 99 | console.print("[bold green]Project initialized successfully![/bold green]") 100 | console.print("\nTo get started, run:") 101 | console.print(f" cd {output_dir.name}") 102 | console.print(" golf build dev") 103 | 104 | # Track successful initialization 105 | track_event("cli_init_success", {"success": True, "template": template}) 106 | except Exception as e: 107 | # Capture error details for telemetry 108 | error_type = type(e).__name__ 109 | error_message = str(e) 110 | 111 | console.print( 112 | f"[bold red]Error during initialization:[/bold red] {error_message}" 113 | ) 114 | track_command( 115 | "init", success=False, error_type=error_type, error_message=error_message 116 | ) 117 | 118 | # Re-raise to maintain existing behavior 119 | raise 120 | 121 | 122 | def _copy_template(source_dir: Path, target_dir: Path, project_name: str) -> None: 123 | """Copy template files to the target directory, with variable substitution. 124 | 125 | Args: 126 | source_dir: Source template directory 127 | target_dir: Target project directory 128 | project_name: Name of the project (for substitutions) 129 | """ 130 | # Create standard directory structure 131 | (target_dir / "tools").mkdir(exist_ok=True) 132 | (target_dir / "resources").mkdir(exist_ok=True) 133 | (target_dir / "prompts").mkdir(exist_ok=True) 134 | 135 | # Copy all files from the template 136 | for source_path in source_dir.glob("**/*"): 137 | # Skip if directory (we'll create directories as needed) 138 | if source_path.is_dir(): 139 | continue 140 | 141 | # Compute relative path 142 | rel_path = source_path.relative_to(source_dir) 143 | target_path = target_dir / rel_path 144 | 145 | # Create parent directories if needed 146 | target_path.parent.mkdir(parents=True, exist_ok=True) 147 | 148 | # Copy and substitute content for text files 149 | if _is_text_file(source_path): 150 | with open(source_path, encoding="utf-8") as f: 151 | content = f.read() 152 | 153 | # Replace template variables 154 | content = content.replace("{{project_name}}", project_name) 155 | content = content.replace( 156 | "{{project_name_lowercase}}", project_name.lower() 157 | ) 158 | 159 | with open(target_path, "w", encoding="utf-8") as f: 160 | f.write(content) 161 | else: 162 | # Binary file, just copy 163 | shutil.copy2(source_path, target_path) 164 | 165 | # Create .env file 166 | env_file = target_dir / ".env" 167 | with open(env_file, "w", encoding="utf-8") as f: 168 | f.write(f"GOLF_NAME={project_name}\n") 169 | f.write("GOLF_HOST=127.0.0.1\n") 170 | f.write("GOLF_PORT=3000\n") 171 | 172 | # Create a .gitignore if it doesn't exist 173 | gitignore_file = target_dir / ".gitignore" 174 | if not gitignore_file.exists(): 175 | with open(gitignore_file, "w", encoding="utf-8") as f: 176 | f.write("# Python\n") 177 | f.write("__pycache__/\n") 178 | f.write("*.py[cod]\n") 179 | f.write("*$py.class\n") 180 | f.write("*.so\n") 181 | f.write(".Python\n") 182 | f.write("env/\n") 183 | f.write("build/\n") 184 | f.write("develop-eggs/\n") 185 | f.write("dist/\n") 186 | f.write("downloads/\n") 187 | f.write("eggs/\n") 188 | f.write(".eggs/\n") 189 | f.write("lib/\n") 190 | f.write("lib64/\n") 191 | f.write("parts/\n") 192 | f.write("sdist/\n") 193 | f.write("var/\n") 194 | f.write("*.egg-info/\n") 195 | f.write(".installed.cfg\n") 196 | f.write("*.egg\n\n") 197 | f.write("# Environment\n") 198 | f.write(".env\n") 199 | f.write(".venv\n") 200 | f.write("env/\n") 201 | f.write("venv/\n") 202 | f.write("ENV/\n") 203 | f.write("env.bak/\n") 204 | f.write("venv.bak/\n\n") 205 | f.write("# GolfMCP\n") 206 | f.write(".golf/\n") 207 | f.write("dist/\n") 208 | 209 | 210 | def _is_text_file(path: Path) -> bool: 211 | """Check if a file is a text file that needs variable substitution. 212 | 213 | Args: 214 | path: Path to check 215 | 216 | Returns: 217 | True if the file is a text file 218 | """ 219 | # List of known text file extensions 220 | text_extensions = { 221 | ".py", 222 | ".md", 223 | ".txt", 224 | ".html", 225 | ".css", 226 | ".js", 227 | ".json", 228 | ".yml", 229 | ".yaml", 230 | ".toml", 231 | ".ini", 232 | ".cfg", 233 | ".env", 234 | ".example", 235 | } 236 | 237 | # Check if the file has a text extension 238 | if path.suffix in text_extensions: 239 | return True 240 | 241 | # Check specific filenames without extensions 242 | if path.name in {".gitignore", "README", "LICENSE"}: 243 | return True 244 | 245 | # Try to detect if it's a text file by reading a bit of it 246 | try: 247 | with open(path, encoding="utf-8") as f: 248 | f.read(1024) 249 | return True 250 | except UnicodeDecodeError: 251 | return False 252 | -------------------------------------------------------------------------------- /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 9 | 10 | from golf.core.config import Settings 11 | 12 | console = Console() 13 | 14 | 15 | def run_server( 16 | project_path: Path, 17 | settings: Settings, 18 | dist_dir: Path | None = None, 19 | host: str | None = None, 20 | port: int | None = None, 21 | ) -> int: 22 | """Run the built FastMCP server. 23 | 24 | Args: 25 | project_path: Path to the project root 26 | settings: Project settings 27 | dist_dir: Path to the directory containing the built server (defaults to project_path/dist) 28 | host: Host to bind the server to (overrides settings) 29 | port: Port to bind the server to (overrides settings) 30 | 31 | Returns: 32 | Process return code 33 | """ 34 | # Set default dist directory if not specified 35 | if dist_dir is None: 36 | dist_dir = project_path / "dist" 37 | 38 | # Check if server file exists 39 | server_path = dist_dir / "server.py" 40 | if not server_path.exists(): 41 | console.print( 42 | f"[bold red]Error: Server file {server_path} not found.[/bold red]" 43 | ) 44 | return 1 45 | 46 | # Prepare environment variables 47 | env = os.environ.copy() 48 | if host is not None: 49 | env["HOST"] = host 50 | elif settings.host: 51 | env["HOST"] = settings.host 52 | 53 | if port is not None: 54 | env["PORT"] = str(port) 55 | elif settings.port: 56 | env["PORT"] = str(settings.port) 57 | 58 | # Run the server 59 | try: 60 | # Using subprocess to properly handle signals (Ctrl+C) 61 | process = subprocess.run( 62 | [sys.executable, str(server_path)], 63 | cwd=dist_dir, 64 | env=env, 65 | ) 66 | 67 | # Provide more context about the exit 68 | if process.returncode == 0: 69 | console.print("[green]Server stopped successfully[/green]") 70 | elif process.returncode == 130: 71 | console.print("[yellow]Server stopped by user interrupt (Ctrl+C)[/yellow]") 72 | elif process.returncode == 143: 73 | console.print( 74 | "[yellow]Server stopped by SIGTERM (graceful shutdown)[/yellow]" 75 | ) 76 | elif process.returncode == 137: 77 | console.print( 78 | "[yellow]Server stopped by SIGKILL (forced shutdown)[/yellow]" 79 | ) 80 | elif process.returncode in [1, 2]: 81 | console.print( 82 | f"[red]Server exited with error code {process.returncode}[/red]" 83 | ) 84 | else: 85 | console.print( 86 | f"[orange]Server exited with code {process.returncode}[/orange]" 87 | ) 88 | 89 | return process.returncode 90 | except KeyboardInterrupt: 91 | console.print("\n[yellow]Server stopped by user (Ctrl+C)[/yellow]") 92 | return 130 # Standard exit code for SIGINT 93 | except Exception as e: 94 | console.print(f"\n[bold red]Error running server:[/bold red] {e}") 95 | return 1 96 | -------------------------------------------------------------------------------- /src/golf/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core functionality for the GolfMCP framework.""" 2 | -------------------------------------------------------------------------------- /src/golf/core/builder_auth.py: -------------------------------------------------------------------------------- 1 | """Authentication integration for the GolfMCP build process. 2 | 3 | This module adds support for injecting authentication configuration 4 | into the generated FastMCP application during the build process. 5 | """ 6 | 7 | from golf.auth import get_auth_config 8 | from golf.auth.api_key import get_api_key_config 9 | 10 | 11 | def generate_auth_code( 12 | server_name: str, 13 | host: str = "127.0.0.1", 14 | port: int = 3000, 15 | https: bool = False, 16 | opentelemetry_enabled: bool = False, 17 | transport: str = "streamable-http", 18 | ) -> dict: 19 | """Generate authentication components for the FastMCP app. 20 | 21 | Returns a dictionary with: 22 | - imports: List of import statements 23 | - setup_code: Auth setup code (provider configuration, etc.) 24 | - fastmcp_args: Dict of arguments to add to FastMCP constructor 25 | - has_auth: Whether auth is configured 26 | """ 27 | # Check for API key configuration first 28 | api_key_config = get_api_key_config() 29 | if api_key_config: 30 | return generate_api_key_auth_components( 31 | server_name, opentelemetry_enabled, transport 32 | ) 33 | 34 | # Otherwise check for OAuth configuration 35 | original_provider_config, required_scopes_from_config = get_auth_config() 36 | 37 | if not original_provider_config: 38 | # If no auth config, return empty components 39 | return {"imports": [], "setup_code": [], "fastmcp_args": {}, "has_auth": False} 40 | 41 | # Build auth components 42 | auth_imports = [ 43 | "import os", 44 | "import sys # For stderr output", 45 | "from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions", 46 | "from golf.auth.provider import ProviderConfig as GolfProviderConfigInternal # Alias to avoid conflict if user also has ProviderConfig", 47 | "from golf.auth.oauth import GolfOAuthProvider", 48 | "# get_access_token and create_callback_handler are used by generated auth_routes", 49 | "from golf.auth import get_access_token, create_callback_handler", 50 | ] 51 | 52 | setup_code_lines = [] 53 | 54 | # Code to determine runtime server address configuration 55 | setup_code_lines.extend( 56 | [ 57 | "# Determine runtime server address configuration", 58 | f"runtime_host = os.environ.get('HOST', {repr(host)})", 59 | f"runtime_port = int(os.environ.get('PORT', {repr(port)}))", 60 | f"runtime_protocol = 'https' if os.environ.get('HTTPS', {repr(str(https)).lower()}).lower() in ('1', 'true', 'yes') else 'http'", 61 | "", 62 | "# Determine proper issuer URL at runtime for this server instance", 63 | "include_port = (runtime_protocol == 'http' and runtime_port != 80) or (runtime_protocol == 'https' and runtime_port != 443)", 64 | "if include_port:", 65 | " runtime_issuer_url = f'{runtime_protocol}://{runtime_host}:{runtime_port}'", 66 | "else:", 67 | " runtime_issuer_url = f'{runtime_protocol}://{runtime_host}'", 68 | "", 69 | ] 70 | ) 71 | 72 | # Code to load secrets from environment variables AT RUNTIME in server.py 73 | setup_code_lines.extend( 74 | [ 75 | "# Load secrets from environment variables using names specified in pre_build.py ProviderConfig", 76 | f"runtime_client_id = os.environ.get({repr(original_provider_config.client_id_env_var)})", 77 | f"runtime_client_secret = os.environ.get({repr(original_provider_config.client_secret_env_var)})", 78 | f"runtime_jwt_secret = os.environ.get({repr(original_provider_config.jwt_secret_env_var)})", 79 | "", 80 | "# Check and warn if essential secrets are missing", 81 | "if not runtime_client_id:", 82 | f" print(f\"AUTH WARNING: Environment variable '{original_provider_config.client_id_env_var}' for OAuth Client ID is not set. Authentication will likely fail.\", file=sys.stderr)", 83 | "if not runtime_client_secret:", 84 | f" print(f\"AUTH WARNING: Environment variable '{original_provider_config.client_secret_env_var}' for OAuth Client Secret is not set. Authentication will likely fail.\", file=sys.stderr)", 85 | "if not runtime_jwt_secret:", 86 | f" print(f\"AUTH WARNING: Environment variable '{original_provider_config.jwt_secret_env_var}' for JWT Secret is not set. Using a default insecure fallback. DO NOT USE IN PRODUCTION.\", file=sys.stderr)", 87 | f" runtime_jwt_secret = {repr(f'fallback-dev-jwt-secret-for-{server_name}-!PLEASE-CHANGE-THIS!')}", # Fixed fallback string 88 | "", 89 | ] 90 | ) 91 | 92 | # Code to instantiate ProviderConfig using runtime-loaded secrets and other baked-in non-secrets 93 | setup_code_lines.extend( 94 | [ 95 | "# Instantiate ProviderConfig with runtime-resolved secrets and other pre-configured values", 96 | "provider_config_instance = GolfProviderConfigInternal(", # Use aliased import 97 | f" provider={repr(original_provider_config.provider)},", 98 | f" client_id_env_var={repr(original_provider_config.client_id_env_var)},", 99 | f" client_secret_env_var={repr(original_provider_config.client_secret_env_var)},", 100 | f" jwt_secret_env_var={repr(original_provider_config.jwt_secret_env_var)},", 101 | " client_id=runtime_client_id,", 102 | " client_secret=runtime_client_secret,", 103 | " jwt_secret=runtime_jwt_secret,", 104 | f" authorize_url={repr(original_provider_config.authorize_url)},", 105 | f" token_url={repr(original_provider_config.token_url)},", 106 | f" userinfo_url={repr(original_provider_config.userinfo_url)},", 107 | f" jwks_uri={repr(original_provider_config.jwks_uri)},", 108 | f" scopes={repr(original_provider_config.scopes)},", 109 | " issuer_url=runtime_issuer_url,", 110 | f" callback_path={repr(original_provider_config.callback_path)},", 111 | f" token_expiration={original_provider_config.token_expiration}", 112 | ")", 113 | "", 114 | "auth_provider = GolfOAuthProvider(provider_config_instance)", 115 | "from golf.auth.helpers import _set_active_golf_oauth_provider # Ensure helper is imported", 116 | "_set_active_golf_oauth_provider(auth_provider) # Make provider instance available", 117 | "", 118 | ] 119 | ) 120 | 121 | # AuthSettings creation 122 | setup_code_lines.extend( 123 | [ 124 | "# Create auth settings for FastMCP", 125 | "auth_settings = AuthSettings(", 126 | " issuer_url=runtime_issuer_url,", 127 | " client_registration_options=ClientRegistrationOptions(", 128 | " enabled=True,", 129 | f" valid_scopes={repr(original_provider_config.scopes)},", 130 | f" default_scopes={repr(original_provider_config.scopes)}", 131 | " ),", 132 | f" required_scopes={repr(required_scopes_from_config) if required_scopes_from_config else None}", 133 | ")", 134 | "", 135 | ] 136 | ) 137 | 138 | # FastMCP constructor arguments 139 | fastmcp_args = {"auth_server_provider": "auth_provider", "auth": "auth_settings"} 140 | 141 | return { 142 | "imports": auth_imports, 143 | "setup_code": setup_code_lines, 144 | "fastmcp_args": fastmcp_args, 145 | "has_auth": True, 146 | } 147 | 148 | 149 | def generate_api_key_auth_components( 150 | server_name: str, 151 | opentelemetry_enabled: bool = False, 152 | transport: str = "streamable-http", 153 | ) -> dict: 154 | """Generate authentication components for API key authentication. 155 | 156 | Returns a dictionary with: 157 | - imports: List of import statements 158 | - setup_code: Auth setup code (middleware setup) 159 | - fastmcp_args: Dict of arguments to add to FastMCP constructor 160 | - has_auth: Whether auth is configured 161 | """ 162 | api_key_config = get_api_key_config() 163 | if not api_key_config: 164 | return {"imports": [], "setup_code": [], "fastmcp_args": {}, "has_auth": False} 165 | 166 | auth_imports = [ 167 | "# API key authentication setup", 168 | "from golf.auth.api_key import get_api_key_config, configure_api_key", 169 | "from golf.auth import set_api_key", 170 | "from starlette.middleware.base import BaseHTTPMiddleware", 171 | "from starlette.requests import Request", 172 | "from starlette.responses import JSONResponse", 173 | "import os", 174 | ] 175 | 176 | setup_code_lines = [ 177 | "# Recreate API key configuration from pre_build.py", 178 | "configure_api_key(", 179 | f" header_name={repr(api_key_config.header_name)},", 180 | f" header_prefix={repr(api_key_config.header_prefix)},", 181 | f" required={repr(api_key_config.required)}", 182 | ")", 183 | "", 184 | "# Simplified API key middleware that validates presence", 185 | "class ApiKeyMiddleware(BaseHTTPMiddleware):", 186 | " async def dispatch(self, request: Request, call_next):", 187 | " # Debug mode from environment", 188 | " debug = os.environ.get('GOLF_API_KEY_DEBUG', '').lower() == 'true'", 189 | " ", 190 | " api_key_config = get_api_key_config()", 191 | " ", 192 | " if api_key_config:", 193 | " # Extract API key from the configured header", 194 | " header_name = api_key_config.header_name", 195 | " header_prefix = api_key_config.header_prefix", 196 | " ", 197 | " # Case-insensitive header lookup", 198 | " api_key = None", 199 | " for k, v in request.headers.items():", 200 | " if k.lower() == header_name.lower():", 201 | " api_key = v", 202 | " break", 203 | " ", 204 | " # Process the API key if found", 205 | " if api_key:", 206 | " # Strip prefix if configured", 207 | " if header_prefix and api_key.startswith(header_prefix):", 208 | " api_key = api_key[len(header_prefix):]", 209 | " ", 210 | " # Store the API key in request state for tools to access", 211 | " request.state.api_key = api_key", 212 | " ", 213 | " # Also store in context variable for tools", 214 | " set_api_key(api_key)", 215 | " ", 216 | " # Check if API key is required but missing", 217 | " if api_key_config.required and not api_key:", 218 | " return JSONResponse(", 219 | " {'error': 'unauthorized', 'detail': f'Missing required {header_name} header'},", 220 | " status_code=401,", 221 | " headers={'WWW-Authenticate': f'{header_name} realm=\"MCP Server\"'}", 222 | " )", 223 | " ", 224 | " # Continue with the request", 225 | " return await call_next(request)", 226 | "", 227 | ] 228 | 229 | # API key auth is handled via middleware, not FastMCP constructor args 230 | fastmcp_args = {} 231 | 232 | return { 233 | "imports": auth_imports, 234 | "setup_code": setup_code_lines, 235 | "fastmcp_args": fastmcp_args, 236 | "has_auth": True, 237 | } 238 | 239 | 240 | def generate_auth_routes() -> str: 241 | """Generate code for OAuth routes in the FastMCP app. 242 | These routes are added to the FastMCP instance (`mcp`) created by `generate_auth_code`. 243 | """ 244 | # API key auth doesn't need special routes 245 | api_key_config = get_api_key_config() 246 | if api_key_config: 247 | return "" 248 | 249 | provider_config, _ = get_auth_config() # Used to check if auth is enabled generally 250 | if not provider_config: 251 | return "" 252 | 253 | auth_routes_list = [ 254 | "", 255 | "# Auth-specific routes, using the 'auth_provider' instance defined in the auth setup code", 256 | "@mcp.custom_route('/auth/callback', methods=['GET'])", 257 | "async def oauth_callback(request):", 258 | " # create_callback_handler is imported in the auth setup code block", 259 | " handler = create_callback_handler(auth_provider)", 260 | " return await handler(request)", 261 | "", 262 | "@mcp.custom_route('/login', methods=['GET'])", 263 | "async def login(request):", 264 | " from starlette.responses import RedirectResponse", 265 | " import urllib.parse", 266 | ' default_redirect_uri = urllib.parse.quote_plus("http://localhost:5173/callback")', 267 | ' authorize_url = f"/mcp/auth/authorize?client_id=default&response_type=code&redirect_uri={default_redirect_uri}"', 268 | " return RedirectResponse(authorize_url)", 269 | "", 270 | "@mcp.custom_route('/auth-error', methods=['GET'])", 271 | "async def auth_error(request):", 272 | " from starlette.responses import HTMLResponse", 273 | " error = request.query_params.get('error', 'unknown_error')", 274 | " error_desc = request.query_params.get('error_description', 'An authentication error occurred')", 275 | ' html_content = f"""', 276 | " ", 277 | " Authentication Error", 278 | " ", 279 | "

Authentication Failed

", 280 | "

Error: {error}

Description: {error_desc}

", 281 | " Try Again\"\"\"", 282 | " return HTMLResponse(content=html_content)", 283 | "", 284 | ] 285 | return "\n".join(auth_routes_list) 286 | -------------------------------------------------------------------------------- /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 | ) -> str: 33 | """Generate component registration code with telemetry instrumentation. 34 | 35 | Args: 36 | component_type: Type of component ('tool', 'resource', 'prompt') 37 | component_name: Name of the component 38 | module_path: Full module path to the component 39 | entry_function: Entry function name 40 | docstring: Component description 41 | uri_template: URI template for resources (optional) 42 | 43 | Returns: 44 | Python code string for registering the component with instrumentation 45 | """ 46 | func_ref = f"{module_path}.{entry_function}" 47 | escaped_docstring = docstring.replace('"', '\\"') if docstring else "" 48 | 49 | if component_type == "tool": 50 | wrapped_func = f"instrument_tool({func_ref}, '{component_name}')" 51 | return f'mcp.add_tool({wrapped_func}, name="{component_name}", description="{escaped_docstring}")' 52 | 53 | elif component_type == "resource": 54 | wrapped_func = f"instrument_resource({func_ref}, '{uri_template}')" 55 | return f'mcp.add_resource_fn({wrapped_func}, uri="{uri_template}", name="{component_name}", description="{escaped_docstring}")' 56 | 57 | elif component_type == "prompt": 58 | wrapped_func = f"instrument_prompt({func_ref}, '{component_name}')" 59 | return f'mcp.add_prompt({wrapped_func}, name="{component_name}", description="{escaped_docstring}")' 60 | 61 | else: 62 | raise ValueError(f"Unknown component type: {component_type}") 63 | 64 | 65 | def get_otel_dependencies() -> list[str]: 66 | """Get list of OpenTelemetry dependencies to add to pyproject.toml. 67 | 68 | Returns: 69 | List of package requirements strings 70 | """ 71 | return [ 72 | "opentelemetry-api>=1.18.0", 73 | "opentelemetry-sdk>=1.18.0", 74 | "opentelemetry-instrumentation-asgi>=0.40b0", 75 | "opentelemetry-exporter-otlp-proto-http>=0.40b0", 76 | ] 77 | -------------------------------------------------------------------------------- /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( 17 | ..., description="Authentication provider (e.g., 'jwks', 'google', 'github')" 18 | ) 19 | scopes: list[str] = Field(default_factory=list, description="Required OAuth scopes") 20 | client_id_env: str | None = Field( 21 | None, description="Environment variable name for client ID" 22 | ) 23 | client_secret_env: str | None = Field( 24 | None, description="Environment variable name for client secret" 25 | ) 26 | redirect_uri: str | None = Field( 27 | None, description="OAuth redirect URI (defaults to localhost callback)" 28 | ) 29 | 30 | @field_validator("provider") 31 | @classmethod 32 | def validate_provider(cls, value: str) -> str: 33 | """Validate the provider value.""" 34 | valid_providers = {"jwks", "google", "github", "custom"} 35 | if value not in valid_providers and not value.startswith("custom:"): 36 | raise ValueError( 37 | f"Invalid provider '{value}'. Must be one of {valid_providers} " 38 | "or start with 'custom:'" 39 | ) 40 | return value 41 | 42 | 43 | class DeployConfig(BaseModel): 44 | """Deployment configuration.""" 45 | 46 | default: str = Field("vercel", description="Default deployment target") 47 | options: dict[str, Any] = Field( 48 | default_factory=dict, description="Target-specific options" 49 | ) 50 | 51 | 52 | class Settings(BaseSettings): 53 | """GolfMCP application settings.""" 54 | 55 | model_config = SettingsConfigDict( 56 | env_prefix="GOLF_", 57 | env_file=".env", 58 | env_file_encoding="utf-8", 59 | extra="ignore", 60 | ) 61 | 62 | # Project metadata 63 | name: str = Field("GolfMCP Project", description="FastMCP instance name") 64 | description: str | None = Field(None, description="Project description") 65 | 66 | # Build settings 67 | output_dir: str = Field("build", description="Build artifact folder") 68 | 69 | # Server settings 70 | host: str = Field("127.0.0.1", description="Server host") 71 | port: int = Field(3000, description="Server port") 72 | transport: str = Field( 73 | "streamable-http", 74 | description="Transport protocol (streamable-http, sse, stdio)", 75 | ) 76 | 77 | # Auth settings 78 | auth: str | AuthConfig | None = Field( 79 | None, description="Authentication configuration or URI" 80 | ) 81 | 82 | # Deploy settings 83 | deploy: DeployConfig = Field( 84 | default_factory=DeployConfig, description="Deployment configuration" 85 | ) 86 | 87 | # Feature flags 88 | telemetry: bool = Field(True, description="Enable anonymous telemetry") 89 | 90 | # Project paths 91 | tools_dir: str = Field("tools", description="Directory containing tools") 92 | resources_dir: str = Field( 93 | "resources", description="Directory containing resources" 94 | ) 95 | prompts_dir: str = Field("prompts", description="Directory containing prompts") 96 | 97 | # OpenTelemetry config 98 | opentelemetry_enabled: bool = Field( 99 | False, description="Enable OpenTelemetry tracing" 100 | ) 101 | opentelemetry_default_exporter: str = Field( 102 | "console", description="Default OpenTelemetry exporter type" 103 | ) 104 | 105 | # Health check configuration 106 | health_check_enabled: bool = Field( 107 | False, description="Enable health check endpoint" 108 | ) 109 | health_check_path: str = Field("/health", description="Health check endpoint path") 110 | health_check_response: str = Field("OK", description="Health check response text") 111 | 112 | 113 | def find_config_path(start_path: Path | None = None) -> Path | None: 114 | """Find the golf config file by searching upwards from the given path. 115 | 116 | Args: 117 | start_path: Path to start searching from (defaults to current directory) 118 | 119 | Returns: 120 | Path to the config file if found, None otherwise 121 | """ 122 | if start_path is None: 123 | start_path = Path.cwd() 124 | 125 | current = start_path.absolute() 126 | 127 | # Don't search above the home directory 128 | home = Path.home().absolute() 129 | 130 | while current != current.parent and current != home: 131 | # Check for JSON config first (preferred) 132 | json_config = current / "golf.json" 133 | if json_config.exists(): 134 | return json_config 135 | 136 | # Fall back to TOML config 137 | toml_config = current / "golf.toml" 138 | if toml_config.exists(): 139 | return toml_config 140 | 141 | current = current.parent 142 | 143 | return None 144 | 145 | 146 | def find_project_root( 147 | start_path: Path | None = None, 148 | ) -> tuple[Path | None, Path | None]: 149 | """Find a GolfMCP project root by searching for a config file. 150 | 151 | This is the central project discovery function that should be used by all commands. 152 | 153 | Args: 154 | start_path: Path to start searching from (defaults to current directory) 155 | 156 | Returns: 157 | Tuple of (project_root, config_path) if a project is found, or (None, None) if not 158 | """ 159 | config_path = find_config_path(start_path) 160 | if config_path: 161 | return config_path.parent, config_path 162 | return None, None 163 | 164 | 165 | def load_settings(project_path: str | Path) -> Settings: 166 | """Load settings from a project directory. 167 | 168 | Args: 169 | project_path: Path to the project directory 170 | 171 | Returns: 172 | Settings object with values loaded from config files 173 | """ 174 | # Convert to Path if needed 175 | if isinstance(project_path, str): 176 | project_path = Path(project_path) 177 | 178 | # Create default settings 179 | settings = Settings() 180 | 181 | # Check for .env file 182 | env_file = project_path / ".env" 183 | if env_file.exists(): 184 | settings = Settings(_env_file=env_file) 185 | 186 | # Try to load JSON config file first 187 | json_config_path = project_path / "golf.json" 188 | if json_config_path.exists(): 189 | return _load_json_settings(json_config_path, settings) 190 | 191 | # Fall back to TOML config file if JSON not found 192 | toml_config_path = project_path / "golf.toml" 193 | if toml_config_path.exists(): 194 | console.print( 195 | "[yellow]Warning: Using .toml configuration is deprecated. Please migrate to .json format.[/yellow]" 196 | ) 197 | return _load_toml_settings(toml_config_path, settings) 198 | 199 | # No config file found, use defaults 200 | return settings 201 | 202 | 203 | def _load_json_settings(path: Path, settings: Settings) -> Settings: 204 | """Load settings from a JSON file.""" 205 | try: 206 | import json 207 | 208 | with open(path) as f: 209 | config_data = json.load(f) 210 | 211 | # Update settings from config data 212 | for key, value in config_data.items(): 213 | if hasattr(settings, key): 214 | setattr(settings, key, value) 215 | 216 | return settings 217 | except Exception as e: 218 | console.print( 219 | f"[bold red]Error loading JSON config from {path}: {e}[/bold red]" 220 | ) 221 | return settings 222 | 223 | 224 | def _load_toml_settings(path: Path, settings: Settings) -> Settings: 225 | """Load settings from a TOML file.""" 226 | 227 | return settings 228 | -------------------------------------------------------------------------------- /src/golf/core/platform.py: -------------------------------------------------------------------------------- 1 | """Platform registration for Golf MCP projects.""" 2 | 3 | import os 4 | from datetime import datetime 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | import httpx 9 | from rich.console import Console 10 | 11 | from golf import __version__ 12 | from golf.core.config import Settings 13 | from golf.core.parser import ComponentType, ParsedComponent 14 | 15 | console = Console() 16 | 17 | 18 | async def register_project_with_platform( 19 | project_path: Path, 20 | settings: Settings, 21 | components: dict[ComponentType, list[ParsedComponent]], 22 | ) -> bool: 23 | """Register project with Golf platform during prod build. 24 | 25 | Args: 26 | project_path: Path to the project root 27 | settings: Project settings 28 | components: Parsed components dictionary 29 | 30 | Returns: 31 | True if registration succeeded or was skipped, False if failed 32 | """ 33 | # Check if platform integration is enabled 34 | api_key = os.getenv("GOLF_API_KEY") 35 | if not api_key: 36 | return True # Skip silently if no API key 37 | 38 | # Require explicit server ID 39 | server_id = os.getenv("GOLF_SERVER_ID") 40 | if not server_id: 41 | console.print( 42 | "[yellow]Warning: Platform registration skipped - GOLF_SERVER_ID environment variable required[/yellow]" 43 | ) 44 | return True # Skip registration but don't fail build 45 | 46 | # Build metadata payload 47 | metadata = { 48 | "project_name": settings.name, 49 | "description": settings.description, 50 | "server_id": server_id, 51 | "components": _build_component_list(components, project_path), 52 | "build_timestamp": datetime.utcnow().isoformat(), 53 | "golf_version": __version__, 54 | "component_counts": _get_component_counts(components), 55 | "server_config": { 56 | "host": settings.host, 57 | "port": settings.port, 58 | "transport": settings.transport, 59 | "auth_enabled": bool(settings.auth), 60 | "telemetry_enabled": settings.opentelemetry_enabled, 61 | "health_check_enabled": settings.health_check_enabled, 62 | }, 63 | } 64 | 65 | try: 66 | async with httpx.AsyncClient(timeout=10.0) as client: 67 | response = await client.post( 68 | "http://localhost:8000/api/resources", 69 | json=metadata, 70 | headers={ 71 | "X-Golf-Key": api_key, 72 | "Content-Type": "application/json", 73 | "User-Agent": f"Golf-MCP/{__version__}", 74 | }, 75 | ) 76 | response.raise_for_status() 77 | 78 | console.print("[green]✓[/green] Registered with Golf platform") 79 | return True 80 | 81 | except httpx.TimeoutException: 82 | console.print("[yellow]Warning: Platform registration timed out[/yellow]") 83 | return False 84 | except httpx.HTTPStatusError as e: 85 | if e.response.status_code == 401: 86 | console.print( 87 | "[yellow]Warning: Platform registration failed - invalid API key[/yellow]" 88 | ) 89 | elif e.response.status_code == 403: 90 | console.print( 91 | "[yellow]Warning: Platform registration failed - access denied[/yellow]" 92 | ) 93 | else: 94 | console.print( 95 | f"[yellow]Warning: Platform registration failed - HTTP {e.response.status_code}[/yellow]" 96 | ) 97 | return False 98 | except Exception as e: 99 | console.print(f"[yellow]Warning: Platform registration failed: {e}[/yellow]") 100 | return False # Don't fail the build 101 | 102 | 103 | def _build_component_list( 104 | components: dict[ComponentType, list[ParsedComponent]], 105 | project_path: Path, 106 | ) -> list[dict[str, Any]]: 107 | """Convert parsed components to platform format. 108 | 109 | Args: 110 | components: Dictionary of parsed components by type 111 | project_path: Path to the project root 112 | 113 | Returns: 114 | List of component metadata dictionaries 115 | """ 116 | result = [] 117 | 118 | for comp_type, comp_list in components.items(): 119 | for comp in comp_list: 120 | # Start with basic component data 121 | component_data = { 122 | "name": comp.name, 123 | "type": comp_type.value, 124 | "description": comp.docstring, 125 | "entry_function": comp.entry_function, 126 | "parent_module": comp.parent_module, 127 | } 128 | 129 | # Add file path relative to project root if available 130 | if comp.file_path: 131 | try: 132 | file_path = Path(comp.file_path) 133 | # Use the provided project_path for relative calculation 134 | rel_path = file_path.relative_to(project_path) 135 | component_data["file_path"] = str(rel_path) 136 | except ValueError: 137 | # If relative_to fails, try to find a common path or use filename 138 | component_data["file_path"] = Path(comp.file_path).name 139 | 140 | # Add schema information only if available and not None 141 | if hasattr(comp, "input_schema") and comp.input_schema: 142 | component_data["input_schema"] = comp.input_schema 143 | if hasattr(comp, "output_schema") and comp.output_schema: 144 | component_data["output_schema"] = comp.output_schema 145 | 146 | # Add component-specific fields only if they have values 147 | if comp_type == ComponentType.RESOURCE: 148 | if hasattr(comp, "uri_template") and comp.uri_template: 149 | component_data["uri_template"] = comp.uri_template 150 | 151 | elif comp_type == ComponentType.TOOL: 152 | if hasattr(comp, "annotations") and comp.annotations: 153 | component_data["annotations"] = comp.annotations 154 | 155 | # Add parameters only if they exist and are not empty 156 | if hasattr(comp, "parameters") and comp.parameters: 157 | component_data["parameters"] = comp.parameters 158 | 159 | result.append(component_data) 160 | 161 | return result 162 | 163 | 164 | def _get_component_counts( 165 | components: dict[ComponentType, list[ParsedComponent]], 166 | ) -> dict[str, int]: 167 | """Get component counts by type. 168 | 169 | Args: 170 | components: Dictionary of parsed components by type 171 | 172 | Returns: 173 | Dictionary with counts for each component type 174 | """ 175 | return { 176 | "tools": len(components.get(ComponentType.TOOL, [])), 177 | "resources": len(components.get(ComponentType.RESOURCE, [])), 178 | "prompts": len(components.get(ComponentType.PROMPT, [])), 179 | "total": sum(len(comp_list) for comp_list in components.values()), 180 | } 181 | -------------------------------------------------------------------------------- /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 | ) -> None: 24 | """Initialize the import transformer. 25 | 26 | Args: 27 | original_path: Path to the original file 28 | target_path: Path to the target file 29 | import_map: Mapping of original module paths to generated paths 30 | project_root: Root path of the project 31 | """ 32 | self.original_path = original_path 33 | self.target_path = target_path 34 | self.import_map = import_map 35 | self.project_root = project_root 36 | 37 | def visit_Import(self, node: ast.Import) -> Any: 38 | """Transform import statements.""" 39 | return node 40 | 41 | def visit_ImportFrom(self, node: ast.ImportFrom) -> Any: 42 | """Transform import from statements.""" 43 | if node.module is None: 44 | return node 45 | 46 | # Handle relative imports 47 | if node.level > 0: 48 | # Calculate the source module path 49 | source_dir = self.original_path.parent 50 | for _ in range(node.level - 1): 51 | source_dir = source_dir.parent 52 | 53 | if node.module: 54 | source_module = source_dir / node.module.replace(".", "/") 55 | else: 56 | source_module = source_dir 57 | 58 | # Check if this is a common module import 59 | source_str = str(source_module.relative_to(self.project_root)) 60 | if source_str in self.import_map: 61 | # Replace with absolute import 62 | new_module = self.import_map[source_str] 63 | return ast.ImportFrom(module=new_module, names=node.names, level=0) 64 | 65 | return node 66 | 67 | 68 | def transform_component( 69 | component: ParsedComponent, 70 | output_file: Path, 71 | project_path: Path, 72 | import_map: dict[str, str], 73 | source_file: Path = None, 74 | ) -> str: 75 | """Transform a GolfMCP component into a standalone FastMCP component. 76 | 77 | Args: 78 | component: Parsed component to transform 79 | output_file: Path to write the transformed component to 80 | project_path: Path to the project root 81 | import_map: Mapping of original module paths to generated paths 82 | source_file: Optional path to source file (for common.py files) 83 | 84 | Returns: 85 | Generated component code 86 | """ 87 | # Read the original file 88 | if source_file is not None: 89 | file_path = source_file 90 | elif component is not None: 91 | file_path = Path(component.file_path) 92 | else: 93 | raise ValueError("Either component or source_file must be provided") 94 | 95 | with open(file_path) as f: 96 | source_code = f.read() 97 | 98 | # Parse the source code into an AST 99 | tree = ast.parse(source_code) 100 | 101 | # Transform imports 102 | transformer = ImportTransformer(file_path, output_file, import_map, project_path) 103 | tree = transformer.visit(tree) 104 | 105 | # Get all imports and docstring 106 | imports = [] 107 | docstring = None 108 | 109 | # Find the module docstring if present 110 | if ( 111 | len(tree.body) > 0 112 | and isinstance(tree.body[0], ast.Expr) 113 | and isinstance(tree.body[0].value, ast.Constant) 114 | and isinstance(tree.body[0].value.value, str) 115 | ): 116 | docstring = tree.body[0].value.value 117 | 118 | # Find imports 119 | for node in tree.body: 120 | if isinstance(node, ast.Import | ast.ImportFrom): 121 | imports.append(node) 122 | 123 | # Generate the transformed code 124 | transformed_imports = ast.unparse(ast.Module(body=imports, type_ignores=[])) 125 | 126 | # Build full transformed code 127 | transformed_code = transformed_imports + "\n\n" 128 | 129 | # Add docstring if present, using proper triple quotes for multi-line docstrings 130 | if docstring: 131 | # Check if docstring contains newlines 132 | if "\n" in docstring: 133 | # Use triple quotes for multi-line docstrings 134 | transformed_code += f'"""{docstring}"""\n\n' 135 | else: 136 | # Use single quotes for single-line docstrings 137 | transformed_code += f'"{docstring}"\n\n' 138 | 139 | # Add the rest of the code except imports and the original docstring 140 | remaining_nodes = [] 141 | for node in tree.body: 142 | # Skip imports 143 | if isinstance(node, ast.Import | ast.ImportFrom): 144 | continue 145 | 146 | # Skip the original docstring 147 | if ( 148 | isinstance(node, ast.Expr) 149 | and isinstance(node.value, ast.Constant) 150 | and isinstance(node.value.value, str) 151 | ): 152 | continue 153 | 154 | remaining_nodes.append(node) 155 | 156 | remaining_code = ast.unparse(ast.Module(body=remaining_nodes, type_ignores=[])) 157 | transformed_code += remaining_code 158 | 159 | # Ensure the directory exists 160 | output_file.parent.mkdir(parents=True, exist_ok=True) 161 | 162 | # Write the transformed code to the output file 163 | with open(output_file, "w") as f: 164 | f.write(transformed_code) 165 | 166 | return transformed_code 167 | -------------------------------------------------------------------------------- /src/golf/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golf-mcp/golf/44365d3fe7479c6f6f6e726e2f4e0c551e2b2eec/src/golf/examples/__init__.py -------------------------------------------------------------------------------- /src/golf/examples/api_key/.env.example: -------------------------------------------------------------------------------- 1 | GOLF_CLIENT_ID="default-client-id" 2 | GOLF_CLIENT_SECRET="default-secret" 3 | JWT_SECRET="example-jwt-secret-for-development-only" 4 | OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces" 5 | OTEL_SERVICE_NAME="golf-mcp" 6 | -------------------------------------------------------------------------------- /src/golf/examples/api_key/README.md: -------------------------------------------------------------------------------- 1 | # GitHub MCP Server with API Key Authentication 2 | 3 | This example demonstrates how to build a GitHub API MCP server using Golf's API key authentication feature. The server wraps common GitHub operations and passes through authentication tokens from MCP clients to the GitHub API. 4 | 5 | ## Features 6 | 7 | This MCP server provides tools for: 8 | 9 | - **Repository Management** 10 | - `list-repos` - List repositories for users, organizations, or the authenticated user 11 | 12 | - **Issue Management** 13 | - `create-issues` - Create new issues with labels 14 | - `list-issues` - List and filter issues by state and labels 15 | 16 | - **Code Search** 17 | - `code-search` - Search for code across GitHub with language and repository filters 18 | 19 | - **User Information** 20 | - `get-users` - Get user profiles or verify authentication 21 | 22 | ## Tool Naming Convention 23 | 24 | Golf automatically derives tool names from the file structure: 25 | - `tools/issues/create.py` → `create-issues` 26 | - `tools/issues/list.py` → `list-issues` 27 | - `tools/repos/list.py` → `list-repos` 28 | - `tools/search/code.py` → `code-search` 29 | - `tools/users/get.py` → `get-users` 30 | 31 | ## Configuration 32 | 33 | The server is configured in `pre_build.py` to extract GitHub tokens from the `Authorization` header: 34 | 35 | ```python 36 | configure_api_key( 37 | header_name="Authorization", 38 | header_prefix="Bearer ", 39 | required=True # Reject requests without a valid API key 40 | ) 41 | ``` 42 | 43 | This configuration: 44 | - Handles GitHub's token format: `Authorization: Bearer ghp_xxxxxxxxxxxx` 45 | - **Enforces authentication**: When `required=True` (default), requests without a valid API key will be rejected with a 401 Unauthorized error 46 | - For optional authentication (pass-through mode), set `required=False` 47 | 48 | ## How It Works 49 | 50 | 1. **Client sends request** with GitHub token in the Authorization header 51 | 2. **Golf middleware** checks if API key is required and present 52 | 3. **If required and missing**, the request is rejected with 401 Unauthorized 53 | 4. **If present**, the token is extracted based on your configuration 54 | 5. **Tools retrieve token** using `get_api_key()` 55 | 6. **Token is forwarded** to GitHub API in the appropriate format 56 | 7. **GitHub validates** the token and returns results 57 | 58 | ## Running the Server 59 | 60 | 1. Build and run: 61 | ```bash 62 | golf build 63 | golf run 64 | ``` 65 | 66 | 2. The server will start on `http://127.0.0.1:3000` (configurable in `golf.json`) 67 | 68 | 3. Test authentication enforcement: 69 | ```bash 70 | # This will fail with 401 Unauthorized 71 | curl http://localhost:3000/mcp 72 | 73 | # This will succeed 74 | curl -H "Authorization: Bearer ghp_your_token_here" http://localhost:3000/mcp 75 | ``` 76 | 77 | ## GitHub Token Permissions 78 | 79 | Depending on which tools you use, you'll need different token permissions: 80 | 81 | - **Public repositories**: No token needed for read-only access 82 | - **Private repositories**: Token with `repo` scope 83 | - **Creating issues**: Token with `repo` or `public_repo` scope 84 | - **User information**: Token with `user` scope -------------------------------------------------------------------------------- /src/golf/examples/api_key/golf.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-mcp-server", 3 | "description": "MCP server for GitHub API operations with API key authentication", 4 | "host": "127.0.0.1", 5 | "port": 3000, 6 | "transport": "sse", 7 | "health_check_enabled": false, 8 | "health_check_path": "/health", 9 | "health_check_response": "OK", 10 | "opentelemetry_enabled": true, 11 | "opentelemetry_default_exporter": "otlp_http" 12 | } -------------------------------------------------------------------------------- /src/golf/examples/api_key/pre_build.py: -------------------------------------------------------------------------------- 1 | """Configure API key authentication for GitHub MCP server.""" 2 | 3 | from golf.auth import configure_api_key 4 | 5 | # Configure Golf to extract GitHub personal access tokens from the Authorization header 6 | # GitHub expects: Authorization: Bearer ghp_xxxx or Authorization: token ghp_xxxx 7 | configure_api_key( 8 | header_name="Authorization", 9 | header_prefix="Bearer ", # Will handle both "Bearer " and "token " prefixes 10 | required=True, # Reject requests without a valid API key 11 | ) 12 | -------------------------------------------------------------------------------- /src/golf/examples/api_key/tools/issues/create.py: -------------------------------------------------------------------------------- 1 | """Create a new issue in a GitHub repository.""" 2 | 3 | from typing import Annotated 4 | 5 | import httpx 6 | from pydantic import BaseModel, Field 7 | 8 | from golf.auth import get_api_key 9 | 10 | 11 | class Output(BaseModel): 12 | """Response from creating an issue.""" 13 | 14 | success: bool 15 | issue_number: int | None = None 16 | issue_url: str | None = None 17 | error: str | None = None 18 | 19 | 20 | async def create( 21 | repo: Annotated[str, Field(description="Repository name in format 'owner/repo'")], 22 | title: Annotated[str, Field(description="Issue title")], 23 | body: Annotated[ 24 | str, Field(description="Issue description/body (supports Markdown)") 25 | ] = "", 26 | labels: Annotated[ 27 | list[str] | None, Field(description="List of label names to apply") 28 | ] = None, 29 | ) -> Output: 30 | """Create a new issue. 31 | 32 | Requires authentication with appropriate permissions. 33 | """ 34 | github_token = get_api_key() 35 | 36 | if not github_token: 37 | return Output( 38 | success=False, 39 | error="Authentication required. Please provide a GitHub token.", 40 | ) 41 | 42 | # Validate repo format 43 | if "/" not in repo: 44 | return Output(success=False, error="Repository must be in format 'owner/repo'") 45 | 46 | url = f"https://api.github.com/repos/{repo}/issues" 47 | 48 | # Build request payload 49 | payload = {"title": title, "body": body} 50 | if labels: 51 | payload["labels"] = labels 52 | 53 | try: 54 | async with httpx.AsyncClient() as client: 55 | response = await client.post( 56 | url, 57 | headers={ 58 | "Authorization": f"Bearer {github_token}", 59 | "Accept": "application/vnd.github.v3+json", 60 | "User-Agent": "Golf-GitHub-MCP-Server", 61 | }, 62 | json=payload, 63 | timeout=10.0, 64 | ) 65 | 66 | response.raise_for_status() 67 | issue_data = response.json() 68 | 69 | return Output( 70 | success=True, 71 | issue_number=issue_data["number"], 72 | issue_url=issue_data["html_url"], 73 | ) 74 | 75 | except httpx.HTTPStatusError as e: 76 | error_messages = { 77 | 401: "Invalid or missing authentication token", 78 | 403: "Insufficient permissions to create issues in this repository", 79 | 404: "Repository not found", 80 | 422: "Invalid request data", 81 | } 82 | return Output( 83 | success=False, 84 | error=error_messages.get( 85 | e.response.status_code, f"GitHub API error: {e.response.status_code}" 86 | ), 87 | ) 88 | except Exception as e: 89 | return Output(success=False, error=f"Failed to create issue: {str(e)}") 90 | 91 | 92 | # Export the function to be used as the tool 93 | export = create 94 | -------------------------------------------------------------------------------- /src/golf/examples/api_key/tools/issues/list.py: -------------------------------------------------------------------------------- 1 | """List issues in a GitHub repository.""" 2 | 3 | from typing import Annotated, Any 4 | 5 | import httpx 6 | from pydantic import BaseModel, Field 7 | 8 | from golf.auth import get_api_key 9 | 10 | 11 | class Output(BaseModel): 12 | """List of issues from the repository.""" 13 | 14 | issues: list[dict[str, Any]] 15 | total_count: int 16 | 17 | 18 | async def list( 19 | repo: Annotated[str, Field(description="Repository name in format 'owner/repo'")], 20 | state: Annotated[ 21 | str, Field(description="Filter by state - 'open', 'closed', or 'all'") 22 | ] = "open", 23 | labels: Annotated[ 24 | str | None, 25 | Field(description="Comma-separated list of label names to filter by"), 26 | ] = None, 27 | per_page: Annotated[ 28 | int, Field(description="Number of results per page (max 100)") 29 | ] = 20, 30 | ) -> Output: 31 | """List issues in a repository. 32 | 33 | Returns issues with their number, title, state, and other metadata. 34 | Pull requests are filtered out from the results. 35 | """ 36 | github_token = get_api_key() 37 | 38 | # Validate repo format 39 | if "/" not in repo: 40 | return Output(issues=[], total_count=0) 41 | 42 | url = f"https://api.github.com/repos/{repo}/issues" 43 | 44 | # Prepare headers 45 | headers = { 46 | "Accept": "application/vnd.github.v3+json", 47 | "User-Agent": "Golf-GitHub-MCP-Server", 48 | } 49 | if github_token: 50 | headers["Authorization"] = f"Bearer {github_token}" 51 | 52 | # Build query parameters 53 | params = {"state": state, "per_page": min(per_page, 100)} 54 | if labels: 55 | params["labels"] = labels 56 | 57 | try: 58 | async with httpx.AsyncClient() as client: 59 | response = await client.get( 60 | url, headers=headers, params=params, timeout=10.0 61 | ) 62 | 63 | response.raise_for_status() 64 | issues_data = response.json() 65 | 66 | # Filter out pull requests and format issues 67 | issues = [] 68 | for issue in issues_data: 69 | # Skip pull requests (they appear in issues endpoint too) 70 | if "pull_request" in issue: 71 | continue 72 | 73 | issues.append( 74 | { 75 | "number": issue["number"], 76 | "title": issue["title"], 77 | "body": issue.get("body", ""), 78 | "state": issue["state"], 79 | "url": issue["html_url"], 80 | "user": issue["user"]["login"], 81 | "labels": [label["name"] for label in issue.get("labels", [])], 82 | } 83 | ) 84 | 85 | return Output(issues=issues, total_count=len(issues)) 86 | 87 | except Exception: 88 | return Output(issues=[], total_count=0) 89 | 90 | 91 | # Export the function to be used as the tool 92 | export = list 93 | -------------------------------------------------------------------------------- /src/golf/examples/api_key/tools/repos/list.py: -------------------------------------------------------------------------------- 1 | """List GitHub repositories for a user or organization.""" 2 | 3 | from typing import Annotated, Any 4 | 5 | import httpx 6 | from pydantic import BaseModel, Field 7 | 8 | from golf.auth import get_api_key 9 | 10 | 11 | class Output(BaseModel): 12 | """List of repositories.""" 13 | 14 | repositories: list[dict[str, Any]] 15 | total_count: int 16 | 17 | 18 | async def list( 19 | username: Annotated[ 20 | str | None, 21 | Field( 22 | description="GitHub username (lists public repos, or all repos if authenticated as this user)" 23 | ), 24 | ] = None, 25 | org: Annotated[ 26 | str | None, 27 | Field( 28 | description="GitHub organization name (lists public repos, or all repos if authenticated member)" 29 | ), 30 | ] = None, 31 | sort: Annotated[ 32 | str, 33 | Field( 34 | description="How to sort results - 'created', 'updated', 'pushed', 'full_name'" 35 | ), 36 | ] = "updated", 37 | per_page: Annotated[ 38 | int, Field(description="Number of results per page (max 100)") 39 | ] = 20, 40 | ) -> Output: 41 | """List GitHub repositories. 42 | 43 | If neither username nor org is provided, lists repositories for the authenticated user. 44 | """ 45 | # Get the GitHub token from the request context 46 | github_token = get_api_key() 47 | 48 | # Determine the API endpoint 49 | if org: 50 | url = f"https://api.github.com/orgs/{org}/repos" 51 | elif username: 52 | url = f"https://api.github.com/users/{username}/repos" 53 | else: 54 | # List repos for authenticated user 55 | if not github_token: 56 | return Output(repositories=[], total_count=0) 57 | url = "https://api.github.com/user/repos" 58 | 59 | # Prepare headers 60 | headers = { 61 | "Accept": "application/vnd.github.v3+json", 62 | "User-Agent": "Golf-GitHub-MCP-Server", 63 | } 64 | if github_token: 65 | headers["Authorization"] = f"Bearer {github_token}" 66 | 67 | try: 68 | async with httpx.AsyncClient() as client: 69 | response = await client.get( 70 | url, 71 | headers=headers, 72 | params={ 73 | "sort": sort, 74 | "per_page": min(per_page, 100), 75 | "type": "all" if not username and not org else None, 76 | }, 77 | timeout=10.0, 78 | ) 79 | 80 | response.raise_for_status() 81 | repos_data = response.json() 82 | 83 | # Format repositories 84 | repositories = [] 85 | for repo in repos_data: 86 | repositories.append( 87 | { 88 | "name": repo["name"], 89 | "full_name": repo["full_name"], 90 | "description": repo.get("description", ""), 91 | "private": repo.get("private", False), 92 | "stars": repo.get("stargazers_count", 0), 93 | "forks": repo.get("forks_count", 0), 94 | "language": repo.get("language", ""), 95 | "url": repo["html_url"], 96 | } 97 | ) 98 | 99 | return Output(repositories=repositories, total_count=len(repositories)) 100 | 101 | except httpx.HTTPStatusError as e: 102 | if e.response.status_code in [401, 404]: 103 | return Output(repositories=[], total_count=0) 104 | else: 105 | return Output(repositories=[], total_count=0) 106 | except Exception: 107 | return Output(repositories=[], total_count=0) 108 | 109 | 110 | # Export the function to be used as the tool 111 | export = list 112 | -------------------------------------------------------------------------------- /src/golf/examples/api_key/tools/search/code.py: -------------------------------------------------------------------------------- 1 | """Search GitHub code.""" 2 | 3 | from typing import Annotated, Any 4 | 5 | import httpx 6 | from pydantic import BaseModel, Field 7 | 8 | from golf.auth import get_api_key 9 | 10 | 11 | class Output(BaseModel): 12 | """Code search results.""" 13 | 14 | results: list[dict[str, Any]] 15 | total_count: int 16 | 17 | 18 | async def search( 19 | query: Annotated[ 20 | str, Field(description="Search query (e.g., 'addClass', 'TODO', etc.)") 21 | ], 22 | language: Annotated[ 23 | str | None, 24 | Field( 25 | description="Filter by programming language (e.g., 'python', 'javascript')" 26 | ), 27 | ] = None, 28 | repo: Annotated[ 29 | str | None, 30 | Field(description="Search within a specific repository (format: 'owner/repo')"), 31 | ] = None, 32 | org: Annotated[ 33 | str | None, 34 | Field(description="Search within repositories of a specific organization"), 35 | ] = None, 36 | per_page: Annotated[ 37 | int, Field(description="Number of results per page (max 100)") 38 | ] = 10, 39 | ) -> Output: 40 | """Search for code on GitHub. 41 | 42 | Without authentication, you're limited to 10 requests per minute. 43 | With authentication, you can make up to 30 requests per minute. 44 | """ 45 | github_token = get_api_key() 46 | 47 | # Build the search query 48 | search_parts = [query] 49 | if language: 50 | search_parts.append(f"language:{language}") 51 | if repo: 52 | search_parts.append(f"repo:{repo}") 53 | if org: 54 | search_parts.append(f"org:{org}") 55 | 56 | search_query = " ".join(search_parts) 57 | 58 | url = "https://api.github.com/search/code" 59 | 60 | # Prepare headers 61 | headers = { 62 | "Accept": "application/vnd.github.v3+json", 63 | "User-Agent": "Golf-GitHub-MCP-Server", 64 | } 65 | if github_token: 66 | headers["Authorization"] = f"Bearer {github_token}" 67 | 68 | try: 69 | async with httpx.AsyncClient() as client: 70 | response = await client.get( 71 | url, 72 | headers=headers, 73 | params={"q": search_query, "per_page": min(per_page, 100)}, 74 | timeout=10.0, 75 | ) 76 | 77 | if response.status_code == 403: 78 | # Rate limit exceeded 79 | return Output(results=[], total_count=0) 80 | 81 | response.raise_for_status() 82 | data = response.json() 83 | 84 | # Format results 85 | results = [] 86 | for item in data.get("items", []): 87 | results.append( 88 | { 89 | "name": item["name"], 90 | "path": item["path"], 91 | "repository": item["repository"]["full_name"], 92 | "url": item["html_url"], 93 | "score": item.get("score", 0.0), 94 | } 95 | ) 96 | 97 | return Output(results=results, total_count=data.get("total_count", 0)) 98 | 99 | except httpx.HTTPStatusError: 100 | return Output(results=[], total_count=0) 101 | except Exception: 102 | return Output(results=[], total_count=0) 103 | 104 | 105 | # Export the function to be used as the tool 106 | export = search 107 | -------------------------------------------------------------------------------- /src/golf/examples/api_key/tools/users/get.py: -------------------------------------------------------------------------------- 1 | """Get GitHub user information.""" 2 | 3 | from typing import Annotated, Any 4 | 5 | import httpx 6 | from pydantic import BaseModel, Field 7 | 8 | from golf.auth import get_api_key 9 | 10 | 11 | class Output(BaseModel): 12 | """User information result.""" 13 | 14 | found: bool 15 | user: dict[str, Any] | None = None 16 | 17 | 18 | async def get( 19 | username: Annotated[ 20 | str | None, 21 | Field(description="GitHub username (if not provided, gets authenticated user)"), 22 | ] = None, 23 | ) -> Output: 24 | """Get information about a GitHub user. 25 | 26 | If no username is provided, returns information about the authenticated user. 27 | This is useful for testing if authentication is working correctly. 28 | """ 29 | github_token = get_api_key() 30 | 31 | # Determine the API endpoint 32 | if username: 33 | url = f"https://api.github.com/users/{username}" 34 | else: 35 | # Get authenticated user - requires token 36 | if not github_token: 37 | return Output(found=False) 38 | url = "https://api.github.com/user" 39 | 40 | # Prepare headers 41 | headers = { 42 | "Accept": "application/vnd.github.v3+json", 43 | "User-Agent": "Golf-GitHub-MCP-Server", 44 | } 45 | if github_token: 46 | headers["Authorization"] = f"Bearer {github_token}" 47 | 48 | try: 49 | async with httpx.AsyncClient() as client: 50 | response = await client.get(url, headers=headers, timeout=10.0) 51 | 52 | response.raise_for_status() 53 | user_data = response.json() 54 | 55 | return Output( 56 | found=True, 57 | user={ 58 | "login": user_data["login"], 59 | "name": user_data.get("name", ""), 60 | "email": user_data.get("email", ""), 61 | "bio": user_data.get("bio", ""), 62 | "company": user_data.get("company", ""), 63 | "location": user_data.get("location", ""), 64 | "public_repos": user_data.get("public_repos", 0), 65 | "followers": user_data.get("followers", 0), 66 | "following": user_data.get("following", 0), 67 | "created_at": user_data["created_at"], 68 | "url": user_data["html_url"], 69 | }, 70 | ) 71 | 72 | except httpx.HTTPStatusError as e: 73 | if e.response.status_code in [401, 404]: 74 | return Output(found=False) 75 | else: 76 | return Output(found=False) 77 | except Exception: 78 | return Output(found=False) 79 | 80 | 81 | # Export the function to be used as the tool 82 | export = get 83 | -------------------------------------------------------------------------------- /src/golf/examples/basic/.env.example: -------------------------------------------------------------------------------- 1 | GOLF_CLIENT_ID="default-client-id" 2 | GOLF_CLIENT_SECRET="default-secret" 3 | JWT_SECRET="example-jwt-secret-for-development-only" 4 | OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces" 5 | OTEL_SERVICE_NAME="golf-mcp" -------------------------------------------------------------------------------- /src/golf/examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # GolfMCP Project Boilerplate 2 | 3 | This directory serves as a starting template for new GolfMCP projects. Initialize a project using the `golf init ` command. 4 | ## About GolfMCP 5 | 6 | GolfMCP is a Python framework designed to build MCP servers with minimal boilerplate. It allows you to define tools, resources, and prompts as simple Python files. These components are then automatically discovered and compiled into a runnable [FastMCP](https://github.com/fastmcp/fastmcp) server. 7 | 8 | ## Getting Started (After `golf init`) 9 | 10 | Once you've initialized your new project from this boilerplate: 11 | 12 | 1. **Navigate to your project directory:** 13 | ```bash 14 | cd your-project-name 15 | ``` 16 | 17 | 2. **Start the development server:** 18 | ```bash 19 | golf build dev # For a development build 20 | # or 21 | golf build prod # For a production build 22 | 23 | golf run 24 | ``` 25 | 26 | ## Project Structure 27 | 28 | Your initialized GolfMCP project will typically have the following structure: 29 | 30 | - `tools/`: Directory for your tool implementations (Python files defining functions an LLM can call). 31 | - `resources/`: Directory for your resource implementations (Python files defining data an LLM can read). 32 | - `prompts/`: Directory for your prompt templates (Python files defining reusable conversation structures). 33 | - `golf.json`: The main configuration file for your project, including settings like the server name, port, and transport. 34 | - `pre_build.py`: (Optional) A Python script that can be used to run custom logic before the build process begins, such as configuring authentication. 35 | - `.env`: File to store environment-specific variables (e.g., API keys). This is created during `golf init`. 36 | 37 | ## Adding New Components 38 | 39 | To add new functionalities: 40 | 41 | - **Tools**: Create a new `.py` file in the `tools/` directory. 42 | - **Resources**: Create a new `.py` file in the `resources/` directory. 43 | - **Prompts**: Create a new `.py` file in the `prompts/` directory. 44 | 45 | Each Python file should generally define a single component. A module-level docstring in the file will be used as the description for the component. See the example files (e.g., `tools/hello.py`, `resources/info.py`) provided in this boilerplate for reference. 46 | 47 | For shared functionality within a component subdirectory (e.g., `tools/payments/common.py`), you can use a `common.py` file. 48 | 49 | ## Documentation 50 | 51 | For comprehensive details on the GolfMCP framework, including component specifications, advanced configurations, CLI commands, and more, please refer to the official documentation: 52 | 53 | [https://docs.golf.dev](https://docs.golf.dev) 54 | 55 | --- 56 | 57 | Happy Building! 58 | 59 |
60 | Made with ❤️ in San Francisco 61 |
-------------------------------------------------------------------------------- /src/golf/examples/basic/golf.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{project_name}}", 3 | "description": "A GolfMCP project", 4 | "host": "127.0.0.1", 5 | "port": 3000, 6 | "transport": "sse", 7 | "health_check_enabled": false, 8 | "health_check_path": "/health", 9 | "health_check_response": "OK", 10 | "opentelemetry_enabled": true, 11 | "opentelemetry_default_exporter": "otlp_http" 12 | } -------------------------------------------------------------------------------- /src/golf/examples/basic/pre_build.py: -------------------------------------------------------------------------------- 1 | """Pre-build configuration for the basic example. 2 | 3 | This file is executed before the build process starts. 4 | It configures GitHub OAuth authentication for the example. 5 | """ 6 | 7 | from golf.auth import ProviderConfig, configure_auth 8 | 9 | # Create GitHub OAuth provider configuration 10 | github_provider = ProviderConfig( 11 | provider="github", 12 | client_id_env_var="GITHUB_CLIENT_ID", 13 | client_secret_env_var="GITHUB_CLIENT_SECRET", 14 | jwt_secret_env_var="JWT_SECRET", 15 | authorize_url="https://github.com/login/oauth/authorize", 16 | token_url="https://github.com/login/oauth/access_token", 17 | userinfo_url="https://api.github.com/user", 18 | scopes=["read:user", "user:email"], 19 | issuer_url="http://127.0.0.1:3000", # This should be your Golf server's accessible URL 20 | callback_path="/auth/callback", # Golf's callback path 21 | token_expiration=3600, # 1 hour 22 | ) 23 | 24 | # Configure authentication with the provider 25 | configure_auth( 26 | provider=github_provider, 27 | required_scopes=["read:user"], # Require read:user scope for protected endpoints 28 | ) 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 its capabilities." 16 | ), 17 | }, 18 | { 19 | "role": "user", 20 | "content": ( 21 | "Welcome to {{project_name}}! This is a project built with GolfMCP. " 22 | "How can I get started?" 23 | ), 24 | }, 25 | ] 26 | 27 | 28 | # Designate the entry point function 29 | export = welcome 30 | -------------------------------------------------------------------------------- /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/{format}" 8 | 9 | 10 | async def current_time(format: str = "full") -> dict[str, Any]: 11 | """Provide the current time in various formats. 12 | 13 | This is a simple resource example that accepts a format parameter. 14 | 15 | Args: 16 | format: The format to return ('full', 'iso', 'unix' or 'rfc') 17 | """ 18 | now = datetime.now() 19 | 20 | # Prepare all possible formats 21 | all_formats = { 22 | "iso": now.isoformat(), 23 | "rfc": now.strftime("%a, %d %b %Y %H:%M:%S %z"), 24 | "unix": int(now.timestamp()), 25 | "formatted": { 26 | "date": now.strftime("%Y-%m-%d"), 27 | "time": now.strftime("%H:%M:%S"), 28 | "timezone": now.astimezone().tzname(), 29 | }, 30 | } 31 | 32 | # Return specific format or all formats 33 | if format == "full": 34 | return all_formats 35 | elif format in all_formats: 36 | return {format: all_formats[format]} 37 | else: 38 | return {"error": f"Unknown format: {format}"} 39 | 40 | 41 | # Designate the entry point function 42 | export = current_time 43 | -------------------------------------------------------------------------------- /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/resources/weather/common.py: -------------------------------------------------------------------------------- 1 | """Weather shared functionality. 2 | 3 | This common.py file demonstrates the recommended pattern for 4 | sharing functionality across multiple resources in a directory. 5 | """ 6 | 7 | import os 8 | 9 | # Read configuration from environment variables 10 | WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY", "mock_key") 11 | WEATHER_API_URL = os.environ.get("WEATHER_API_URL", "https://api.example.com/weather") 12 | TEMPERATURE_UNIT = os.environ.get("WEATHER_TEMP_UNIT", "fahrenheit") 13 | 14 | 15 | class WeatherApiClient: 16 | """Mock weather API client.""" 17 | 18 | def __init__( 19 | self, api_key: str = WEATHER_API_KEY, api_url: str = WEATHER_API_URL 20 | ) -> 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): 26 | """Get weather forecast for a city (mock implementation).""" 27 | # This would make an API call in a real implementation 28 | print( 29 | f"Would call {self.api_url}/forecast/{city} with API key {self.api_key[:4]}..." 30 | ) 31 | return { 32 | "city": city, 33 | "unit": self.unit, 34 | "forecast": [{"day": i, "temp": 70 + i} for i in range(days)], 35 | } 36 | 37 | async def get_current(self, city: str): 38 | """Get current weather for a city (mock implementation).""" 39 | print( 40 | f"Would call {self.api_url}/current/{city} with API key {self.api_key[:4]}..." 41 | ) 42 | return { 43 | "city": city, 44 | "unit": self.unit, 45 | "temperature": 72, 46 | "conditions": "Sunny", 47 | } 48 | 49 | 50 | # Create a shared weather client that can be imported by all resources in this directory 51 | weather_client = WeatherApiClient() 52 | 53 | # This could also define shared models or other utilities 54 | # that would be common across weather-related resources 55 | -------------------------------------------------------------------------------- /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 .common import weather_client 7 | 8 | # The URI that clients will use to access this resource 9 | resource_uri = "weather://current/{city}" 10 | 11 | 12 | async def current_weather(city: str) -> dict[str, Any]: 13 | """Provide current weather for the specified city. 14 | 15 | This example demonstrates: 16 | 1. Nested resource organization (resources/weather/current.py) 17 | 2. Dynamic URI parameters (city in this case) 18 | 3. Using shared client from the common.py file 19 | """ 20 | # Use the shared weather client from common.py 21 | weather_data = await weather_client.get_current(city) 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 .common import weather_client 7 | 8 | # The URI that clients will use to access this resource 9 | resource_uri = "weather://forecast/{city}" 10 | 11 | 12 | async def forecast_weather(city: str) -> dict[str, Any]: 13 | """Provide a weather forecast for the specified city. 14 | 15 | This example demonstrates: 16 | 1. Nested resource organization (resources/weather/forecast.py) 17 | 2. Dynamic URI parameters (city in this case) 18 | 3. Using shared client from the common.py file 19 | """ 20 | # Use the shared weather client from common.py 21 | forecast_data = await weather_client.get_forecast(city, 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/tools/github_user.py: -------------------------------------------------------------------------------- 1 | """Tool for fetching GitHub user information.""" 2 | 3 | import httpx 4 | from pydantic import BaseModel 5 | 6 | from golf.auth import get_provider_token 7 | 8 | 9 | class GitHubUserResponse(BaseModel): 10 | """Response model for GitHub user information.""" 11 | 12 | login: str 13 | id: int 14 | name: str | None = None 15 | email: str | None = None 16 | avatar_url: str | None = None 17 | location: str | None = None 18 | bio: str | None = None 19 | public_repos: int = 0 20 | followers: int = 0 21 | following: int = 0 22 | message: str | None = None 23 | 24 | 25 | async def get_github_user() -> GitHubUserResponse: 26 | """Fetch authenticated user's GitHub profile information.""" 27 | try: 28 | # Get GitHub token using our abstraction 29 | github_token = get_provider_token() 30 | 31 | if not github_token: 32 | return GitHubUserResponse( 33 | login="anonymous", 34 | id=0, 35 | message="Not authenticated. Please login first.", 36 | ) 37 | 38 | # Call GitHub API to get user info 39 | async with httpx.AsyncClient() as client: 40 | response = await client.get( 41 | "https://api.github.com/user", 42 | headers={ 43 | "Authorization": f"Bearer {github_token}", 44 | "Accept": "application/vnd.github.v3+json", 45 | }, 46 | ) 47 | 48 | if response.status_code == 200: 49 | data = response.json() 50 | return GitHubUserResponse(**data) 51 | else: 52 | return GitHubUserResponse( 53 | login="error", 54 | id=0, 55 | message=f"GitHub API error: {response.status_code} - {response.text[:100]}", 56 | ) 57 | 58 | except Exception as e: 59 | return GitHubUserResponse( 60 | login="error", id=0, message=f"Error fetching GitHub data: {str(e)}" 61 | ) 62 | 63 | 64 | # Export the tool 65 | export = get_github_user 66 | -------------------------------------------------------------------------------- /src/golf/examples/basic/tools/hello.py: -------------------------------------------------------------------------------- 1 | """Hello World tool {{project_name}}.""" 2 | 3 | from typing import Annotated 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | 8 | class Output(BaseModel): 9 | """Response from the hello tool.""" 10 | 11 | message: str 12 | 13 | 14 | async def hello( 15 | name: Annotated[ 16 | str, Field(description="The name of the person to greet") 17 | ] = "World", 18 | greeting: Annotated[str, Field(description="The greeting phrase to use")] = "Hello", 19 | ) -> Output: 20 | """Say hello to the given name. 21 | 22 | This is a simple example tool that demonstrates the basic structure 23 | of a tool implementation in GolfMCP. 24 | """ 25 | # The framework will add a context object automatically 26 | # You can log using regular print during development 27 | print(f"{greeting} {name}...") 28 | 29 | # Create and return the response 30 | return Output(message=f"{greeting}, {name}!") 31 | 32 | 33 | # Designate the entry point function 34 | export = hello 35 | -------------------------------------------------------------------------------- /src/golf/examples/basic/tools/payments/charge.py: -------------------------------------------------------------------------------- 1 | """Charge payment tool""" 2 | 3 | from typing import Annotated 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | from .common import payment_client 8 | 9 | 10 | class Output(BaseModel): 11 | """Response from the charge payment tool.""" 12 | 13 | success: bool 14 | charge_id: str 15 | message: str 16 | 17 | 18 | async def charge( 19 | amount: Annotated[ 20 | float, 21 | Field( 22 | description="Amount to charge in USD", 23 | gt=0, # Must be greater than 0 24 | le=10000, # Maximum charge limit 25 | ), 26 | ], 27 | card_token: Annotated[ 28 | str, 29 | Field( 30 | description="Tokenized payment card identifier", 31 | pattern=r"^tok_[a-zA-Z0-9]+$", # Validate token format 32 | ), 33 | ], 34 | description: Annotated[ 35 | str, 36 | Field( 37 | description="Optional payment description for the charge", max_length=200 38 | ), 39 | ] = "", 40 | ) -> Output: 41 | """Process a payment charge. 42 | 43 | This example demonstrates nested directory organization where related tools 44 | are grouped in subdirectories (tools/payments/charge.py). 45 | 46 | The resulting tool ID will be: charge-payments 47 | 48 | Args: 49 | amount: Amount to charge in USD 50 | card_token: Tokenized payment card 51 | description: Optional payment description 52 | """ 53 | # The framework will add a context object automatically 54 | # You can log using regular print during development 55 | print(f"Processing charge for ${amount:.2f}...") 56 | 57 | # Use the shared payment client from common.py 58 | charge_result = await payment_client.create_charge( 59 | amount=amount, token=card_token, description=description 60 | ) 61 | 62 | # Create and return the response 63 | return Output( 64 | success=True, 65 | charge_id=charge_result["id"], 66 | message=f"Successfully charged ${amount:.2f}", 67 | ) 68 | 69 | 70 | export = charge 71 | -------------------------------------------------------------------------------- /src/golf/examples/basic/tools/payments/common.py: -------------------------------------------------------------------------------- 1 | """Payments shared functionality. 2 | 3 | This common.py file demonstrates the recommended pattern for 4 | sharing functionality across multiple tools in a directory. 5 | """ 6 | 7 | import os 8 | 9 | # Read configuration from environment variables 10 | PAYMENT_API_KEY = os.environ.get("PAYMENT_API_KEY", "mock_key") 11 | PAYMENT_API_URL = os.environ.get("PAYMENT_API_URL", "https://api.example.com/payments") 12 | 13 | 14 | class PaymentClient: 15 | """Mock payment provider client.""" 16 | 17 | def __init__( 18 | self, api_key: str = PAYMENT_API_KEY, api_url: str = PAYMENT_API_URL 19 | ) -> None: 20 | self.api_key = api_key 21 | self.api_url = api_url 22 | 23 | async def create_charge(self, amount: float, token: str, **kwargs): 24 | """Create a charge (mock implementation).""" 25 | # In a real implementation, this would make an API request 26 | # using the configured API key and URL 27 | return {"id": f"ch_{int(amount * 100)}_{hash(token) % 10000:04d}"} 28 | 29 | async def create_refund(self, charge_id: str, amount: float, **kwargs): 30 | """Create a refund (mock implementation).""" 31 | # In a real implementation, this would make an API request 32 | return {"id": f"ref_{charge_id}_{int(amount * 100)}"} 33 | 34 | 35 | # Create a shared payment client that can be imported by all tools in this directory 36 | payment_client = PaymentClient() 37 | -------------------------------------------------------------------------------- /src/golf/examples/basic/tools/payments/refund.py: -------------------------------------------------------------------------------- 1 | """Refund payment tool""" 2 | 3 | from typing import Annotated 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | from .common import payment_client 8 | 9 | 10 | class Output(BaseModel): 11 | """Response from the refund payment tool.""" 12 | 13 | success: bool 14 | refund_id: str 15 | message: str 16 | 17 | 18 | async def refund( 19 | charge_id: Annotated[ 20 | str, 21 | Field( 22 | description="The ID of the charge to refund", pattern=r"^ch_[a-zA-Z0-9]+$" 23 | ), 24 | ], 25 | amount: Annotated[ 26 | float | None, 27 | Field( 28 | description="Amount to refund in USD. If not specified, refunds the full charge amount", 29 | gt=0, 30 | default=None, 31 | ), 32 | ] = None, 33 | reason: Annotated[ 34 | str, Field(description="Reason for the refund", min_length=3, max_length=200) 35 | ] = "Customer request", 36 | ) -> Output: 37 | """Process a payment refund. 38 | 39 | This example demonstrates nested directory organization where related tools 40 | are grouped in subdirectories (tools/payments/refund.py). 41 | 42 | The resulting tool ID will be: refund-payments 43 | """ 44 | # The framework will add a context object automatically 45 | # You can log using regular print during development 46 | print(f"Processing refund for charge {charge_id}...") 47 | 48 | # Use the shared payment client from common.py 49 | refund_result = await payment_client.create_refund( 50 | charge_id=charge_id, amount=amount, reason=reason 51 | ) 52 | 53 | # Create and return the response 54 | return Output( 55 | success=True, 56 | refund_id=refund_result["id"], 57 | message=f"Successfully refunded charge {charge_id}", 58 | ) 59 | 60 | 61 | export = refund 62 | -------------------------------------------------------------------------------- /src/golf/telemetry/__init__.py: -------------------------------------------------------------------------------- 1 | """Golf telemetry module for OpenTelemetry instrumentation.""" 2 | 3 | from golf.telemetry.instrumentation import ( 4 | get_tracer, 5 | init_telemetry, 6 | instrument_prompt, 7 | instrument_resource, 8 | instrument_tool, 9 | telemetry_lifespan, 10 | ) 11 | 12 | __all__ = [ 13 | "instrument_tool", 14 | "instrument_resource", 15 | "instrument_prompt", 16 | "telemetry_lifespan", 17 | "init_telemetry", 18 | "get_tracer", 19 | ] 20 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the Golf MCP framework.""" 2 | -------------------------------------------------------------------------------- /tests/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golf-mcp/golf/44365d3fe7479c6f6f6e726e2f4e0c551e2b2eec/tests/auth/__init__.py -------------------------------------------------------------------------------- /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( 42 | header_name="Authorization", header_prefix="Bearer ", required=False 43 | ) 44 | 45 | assert is_api_key_configured() 46 | config = get_api_key_config() 47 | assert config is not None 48 | assert config.header_name == "Authorization" 49 | assert config.header_prefix == "Bearer " 50 | assert config.required is False 51 | 52 | def test_clear_api_key_configuration(self) -> None: 53 | """Test clearing API key configuration.""" 54 | # Configure first 55 | configure_api_key() 56 | assert is_api_key_configured() 57 | 58 | # Clear configuration by setting to None 59 | from golf.auth import api_key 60 | 61 | api_key._api_key_config = None 62 | 63 | assert not is_api_key_configured() 64 | assert get_api_key_config() is None 65 | 66 | def test_api_key_persistence(self) -> None: 67 | """Test that API key configuration persists across calls.""" 68 | # Reset configuration 69 | from golf.auth import api_key 70 | 71 | api_key._api_key_config = None 72 | 73 | # Configure API key 74 | configure_api_key(header_name="Custom-Key") 75 | 76 | # Check it's still there 77 | assert is_api_key_configured() 78 | config1 = get_api_key_config() 79 | 80 | # Get config again 81 | config2 = get_api_key_config() 82 | 83 | assert config1 == config2 84 | assert config2.header_name == "Custom-Key" 85 | -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golf-mcp/golf/44365d3fe7479c6f6f6e726e2f4e0c551e2b2eec/tests/commands/__init__.py -------------------------------------------------------------------------------- /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, template="basic") 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 / ".env").exists() 27 | assert (project_dir / ".gitignore").exists() 28 | 29 | def test_golf_json_has_correct_content(self, temp_dir: Path) -> None: 30 | """Test that golf.json is created with correct content.""" 31 | project_dir = temp_dir / "test_project" 32 | 33 | initialize_project("test_project", project_dir, template="basic") 34 | 35 | config = json.loads((project_dir / "golf.json").read_text()) 36 | assert config["name"] == "test_project" 37 | assert "description" in config 38 | assert config["host"] == "127.0.0.1" 39 | assert config["port"] == 3000 40 | 41 | def test_template_variable_substitution(self, temp_dir: Path) -> None: 42 | """Test that {{project_name}} is replaced correctly.""" 43 | project_dir = temp_dir / "MyApp" 44 | 45 | initialize_project("MyApp", project_dir, template="basic") 46 | 47 | # Check that project name was substituted in files 48 | config = json.loads((project_dir / "golf.json").read_text()) 49 | assert config["name"] == "MyApp" 50 | 51 | # Check .env file 52 | env_content = (project_dir / ".env").read_text() 53 | assert "GOLF_NAME=MyApp" in env_content 54 | 55 | def test_handles_existing_empty_directory(self, temp_dir: Path) -> None: 56 | """Test that init works with an existing empty directory.""" 57 | project_dir = temp_dir / "existing" 58 | project_dir.mkdir() 59 | 60 | # Should not raise an error 61 | initialize_project("existing", project_dir, template="basic") 62 | 63 | assert (project_dir / "golf.json").exists() 64 | 65 | @pytest.mark.skip(reason="Requires interactive input handling") 66 | def test_prompts_for_non_empty_directory(self, temp_dir: Path) -> None: 67 | """Test that init prompts when directory is not empty.""" 68 | # This would require mocking the Confirm.ask prompt 69 | pass 70 | 71 | def test_basic_template_includes_health_check(self, temp_dir: Path) -> None: 72 | """Test that basic template includes health check configuration.""" 73 | project_dir = temp_dir / "health_check_project" 74 | 75 | initialize_project("health_check_project", project_dir, template="basic") 76 | 77 | # Check that golf.json includes health check configuration (disabled by default) 78 | config = json.loads((project_dir / "golf.json").read_text()) 79 | assert config["health_check_enabled"] is False # Should be disabled by default 80 | assert config["health_check_path"] == "/health" 81 | assert config["health_check_response"] == "OK" 82 | 83 | def test_api_key_template_compatibility_with_health_check( 84 | self, temp_dir: Path 85 | ) -> None: 86 | """Test that API key template is compatible with health check configuration.""" 87 | project_dir = temp_dir / "api_key_project" 88 | 89 | initialize_project("api_key_project", project_dir, template="api_key") 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 | -------------------------------------------------------------------------------- /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": "127.0.0.1", 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 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golf-mcp/golf/44365d3fe7479c6f6f6e726e2f4e0c551e2b2eec/tests/core/__init__.py -------------------------------------------------------------------------------- /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 == "127.0.0.1" 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golf-mcp/golf/44365d3fe7479c6f6f6e726e2f4e0c551e2b2eec/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golf-mcp/golf/44365d3fe7479c6f6f6e726e2f4e0c551e2b2eec/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_health_check.py: -------------------------------------------------------------------------------- 1 | """Integration tests for health check functionality.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | 7 | from golf.commands.build import build_project 8 | from golf.commands.init import initialize_project 9 | from golf.core.config import load_settings 10 | 11 | 12 | class TestHealthCheckIntegration: 13 | """Integration tests for health check functionality from init to build.""" 14 | 15 | def test_health_check_full_workflow(self, temp_dir: Path) -> None: 16 | """Test complete workflow: init project with health check -> build -> verify output.""" 17 | project_dir = temp_dir / "health_check_project" 18 | 19 | # Initialize project 20 | initialize_project("health_check_project", project_dir, template="basic") 21 | 22 | # Update config to enable health check 23 | config_file = project_dir / "golf.json" 24 | config = json.loads(config_file.read_text()) 25 | config.update( 26 | { 27 | "health_check_enabled": True, 28 | "health_check_path": "/healthz", 29 | "health_check_response": "Healthy", 30 | } 31 | ) 32 | config_file.write_text(json.dumps(config, indent=2)) 33 | 34 | # Build the project 35 | settings = load_settings(project_dir) 36 | output_dir = project_dir / "dist" 37 | 38 | build_project(project_dir, settings, output_dir, build_env="prod") 39 | 40 | # Verify build output 41 | server_file = output_dir / "server.py" 42 | assert server_file.exists() 43 | 44 | server_code = server_file.read_text() 45 | 46 | # Verify health check is included 47 | assert "from starlette.requests import Request" in server_code 48 | assert "from starlette.responses import PlainTextResponse" in server_code 49 | assert '@mcp.custom_route("/healthz", methods=["GET"])' in server_code 50 | assert 'return PlainTextResponse("Healthy")' in server_code 51 | 52 | # Verify pyproject.toml includes required dependencies 53 | pyproject_file = output_dir / "pyproject.toml" 54 | assert pyproject_file.exists() 55 | pyproject_content = pyproject_file.read_text() 56 | assert "fastmcp" in pyproject_content 57 | 58 | def test_health_check_with_api_key_template(self, temp_dir: Path) -> None: 59 | """Test health check integration with API key template.""" 60 | project_dir = temp_dir / "api_key_health_project" 61 | 62 | # Initialize with API key template 63 | initialize_project("api_key_health_project", project_dir, template="api_key") 64 | 65 | # Update config to enable health check 66 | config_file = project_dir / "golf.json" 67 | config = json.loads(config_file.read_text()) 68 | config.update( 69 | { 70 | "health_check_enabled": True, 71 | "health_check_path": "/health", 72 | "health_check_response": "API server is healthy", 73 | } 74 | ) 75 | config_file.write_text(json.dumps(config, indent=2)) 76 | 77 | # Build the project 78 | settings = load_settings(project_dir) 79 | output_dir = project_dir / "dist" 80 | 81 | build_project(project_dir, settings, output_dir, build_env="dev", copy_env=True) 82 | 83 | # Verify both health check and auth components are present 84 | server_file = output_dir / "server.py" 85 | server_code = server_file.read_text() 86 | 87 | # Health check should be present 88 | assert '@mcp.custom_route("/health", methods=["GET"])' in server_code 89 | assert 'return PlainTextResponse("API server is healthy")' in server_code 90 | 91 | # API key auth should also be present (from template) 92 | assert "configure_api_key" in server_code or "API" in server_code 93 | 94 | def test_health_check_disabled_by_default(self, temp_dir: Path) -> None: 95 | """Test that health check is disabled by default in new projects.""" 96 | project_dir = temp_dir / "default_project" 97 | 98 | # Initialize project with defaults 99 | initialize_project("default_project", project_dir, template="basic") 100 | 101 | # Build without modifying config 102 | settings = load_settings(project_dir) 103 | output_dir = project_dir / "dist" 104 | 105 | build_project(project_dir, settings, output_dir, build_env="prod") 106 | 107 | # Verify health check is NOT included 108 | server_file = output_dir / "server.py" 109 | server_code = server_file.read_text() 110 | 111 | assert "@mcp.custom_route" not in server_code 112 | assert "health_check" not in server_code 113 | assert "PlainTextResponse" not in server_code 114 | 115 | def test_health_check_with_opentelemetry(self, temp_dir: Path) -> None: 116 | """Test health check integration with OpenTelemetry enabled.""" 117 | project_dir = temp_dir / "otel_health_project" 118 | 119 | # Initialize project 120 | initialize_project("otel_health_project", project_dir, template="basic") 121 | 122 | # Update config to enable both health check and OpenTelemetry 123 | config_file = project_dir / "golf.json" 124 | config = json.loads(config_file.read_text()) 125 | config.update( 126 | { 127 | "health_check_enabled": True, 128 | "health_check_path": "/health", 129 | "health_check_response": "Service OK", 130 | "opentelemetry_enabled": True, 131 | "opentelemetry_default_exporter": "console", 132 | } 133 | ) 134 | config_file.write_text(json.dumps(config, indent=2)) 135 | 136 | # Build the project 137 | settings = load_settings(project_dir) 138 | output_dir = project_dir / "dist" 139 | 140 | build_project(project_dir, settings, output_dir, build_env="prod") 141 | 142 | # Verify both health check and OpenTelemetry are present 143 | server_file = output_dir / "server.py" 144 | server_code = server_file.read_text() 145 | 146 | # Health check should be present 147 | assert '@mcp.custom_route("/health", methods=["GET"])' in server_code 148 | assert 'return PlainTextResponse("Service OK")' in server_code 149 | 150 | # OpenTelemetry should be present 151 | assert "opentelemetry" in server_code or "telemetry" in server_code 152 | 153 | def test_health_check_with_multiple_transports(self, temp_dir: Path) -> None: 154 | """Test health check works with different transport configurations.""" 155 | transport_configs = [ 156 | {"transport": "sse"}, 157 | {"transport": "streamable-http"}, 158 | {"transport": "stdio"}, 159 | ] 160 | 161 | for i, transport_config in enumerate(transport_configs): 162 | project_dir = temp_dir / f"transport_project_{i}" 163 | 164 | # Initialize project 165 | initialize_project(f"transport_project_{i}", project_dir, template="basic") 166 | 167 | # Update config 168 | config_file = project_dir / "golf.json" 169 | config = json.loads(config_file.read_text()) 170 | config.update( 171 | { 172 | "health_check_enabled": True, 173 | "health_check_path": "/health", 174 | "health_check_response": f"Transport {transport_config['transport']} OK", 175 | **transport_config, 176 | } 177 | ) 178 | config_file.write_text(json.dumps(config, indent=2)) 179 | 180 | # Build the project 181 | settings = load_settings(project_dir) 182 | output_dir = project_dir / "dist" 183 | 184 | build_project(project_dir, settings, output_dir, build_env="prod") 185 | 186 | # Verify health check is present regardless of transport 187 | server_file = output_dir / "server.py" 188 | server_code = server_file.read_text() 189 | 190 | if transport_config["transport"] != "stdio": 191 | # HTTP-based transports should have health check 192 | assert '@mcp.custom_route("/health", methods=["GET"])' in server_code 193 | assert ( 194 | f'return PlainTextResponse("Transport {transport_config["transport"]} OK")' 195 | in server_code 196 | ) 197 | else: 198 | # stdio transport should still include health check code, even if not usable 199 | assert '@mcp.custom_route("/health", methods=["GET"])' in server_code 200 | 201 | 202 | class TestHealthCheckValidation: 203 | """Test validation and error handling for health check configuration.""" 204 | 205 | def test_health_check_with_invalid_path(self, temp_dir: Path) -> None: 206 | """Test that health check works with various path formats.""" 207 | project_dir = temp_dir / "path_test_project" 208 | 209 | # Initialize project 210 | initialize_project("path_test_project", project_dir, template="basic") 211 | 212 | # Test various path formats 213 | test_paths = ["/health", "/api/health", "/status/check", "/healthz", "/ping"] 214 | 215 | for path in test_paths: 216 | # Update config 217 | config_file = project_dir / "golf.json" 218 | config = json.loads(config_file.read_text()) 219 | config.update( 220 | { 221 | "health_check_enabled": True, 222 | "health_check_path": path, 223 | "health_check_response": "OK", 224 | } 225 | ) 226 | config_file.write_text(json.dumps(config, indent=2)) 227 | 228 | # Build should succeed 229 | settings = load_settings(project_dir) 230 | output_dir = project_dir / "dist" 231 | 232 | # Clean output directory 233 | if output_dir.exists(): 234 | import shutil 235 | 236 | shutil.rmtree(output_dir) 237 | 238 | build_project(project_dir, settings, output_dir, build_env="prod") 239 | 240 | # Verify correct path is used 241 | server_file = output_dir / "server.py" 242 | server_code = server_file.read_text() 243 | 244 | assert f'@mcp.custom_route("{path}", methods=["GET"])' in server_code 245 | 246 | def test_health_check_with_special_characters_in_response( 247 | self, temp_dir: Path 248 | ) -> None: 249 | """Test health check with special characters in response text.""" 250 | project_dir = temp_dir / "special_chars_project" 251 | 252 | # Initialize project 253 | initialize_project("special_chars_project", project_dir, template="basic") 254 | 255 | # Test response with special characters 256 | special_response = 'Service "Health" Status: OK & Running!' 257 | 258 | # Update config 259 | config_file = project_dir / "golf.json" 260 | config = json.loads(config_file.read_text()) 261 | config.update( 262 | { 263 | "health_check_enabled": True, 264 | "health_check_path": "/health", 265 | "health_check_response": special_response, 266 | } 267 | ) 268 | config_file.write_text(json.dumps(config, indent=2)) 269 | 270 | # Build should succeed 271 | settings = load_settings(project_dir) 272 | output_dir = project_dir / "dist" 273 | 274 | build_project(project_dir, settings, output_dir, build_env="prod") 275 | 276 | # Verify response is properly escaped in generated code 277 | server_file = output_dir / "server.py" 278 | server_code = server_file.read_text() 279 | 280 | # The response should be present (possibly escaped) 281 | assert "PlainTextResponse(" in server_code 282 | assert "Health" in server_code 283 | assert "Running" in server_code 284 | --------------------------------------------------------------------------------