``` ├── .git-blame-ignore-revs ├── .github/ ├── ISSUE_TEMPLATE/ ├── bug_report.md ├── feature_request.md ├── workflows/ ├── check-lock.yml ├── go-checks.yaml ├── main-checks.yml ├── publish-docs-manually.yml ├── publish-pypi.yml ├── pull-request-checks.yml ├── shared.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── SECURITY.md ├── assets/ ├── logo.png ├── mcpengine-smack-demo.gif ├── docs/ ├── api.md ├── index.md ├── examples/ ├── README.md ├── clients/ ├── simple-chatbot/ ├── .python-version ├── README.MD ├── mcp_simple_chatbot/ ├── .env.example ├── main.py ├── requirements.txt ├── servers_config.json ├── test.db ├── pyproject.toml ├── uv.lock ├── mcpengine/ ├── complex_inputs.py ├── desktop.py ├── echo.py ├── memory.py ├── parameter_descriptions.py ├── readme-quickstart.py ├── screenshot.py ├── simple_echo.py ├── text_me.py ├── unicode_example.py ├── servers/ ├── lambda-weather/ ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── pyproject.toml ├── terraform/ ├── .terraform.lock.hcl ├── main.tf ├── outputs.tf ├── providers.tf ├── variables.tf ├── weather/ ├── __init__.py ├── __main__.py ├── server.py ├── simple-prompt/ ├── .python-version ├── README.md ├── mcp_simple_prompt/ ├── __init__.py ├── __main__.py ├── server.py ├── pyproject.toml ├── simple-resource/ ├── .python-version ├── README.md ├── mcp_simple_resource/ ├── __init__.py ├── __main__.py ├── server.py ├── pyproject.toml ├── simple-tool/ ├── .python-version ├── README.md ├── mcp_simple_tool/ ├── __init__.py ├── __main__.py ├── server.py ├── pyproject.toml ├── smack/ ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── mcp_smack/ ├── __init__.py ├── __main__.py ├── db/ ├── __init__.py ├── postgres.py ├── server.py ├── pyproject.toml ├── terraform/ ├── .terraform.lock.hcl ├── db.tf ├── ecr.tf ├── lambda.tf ├── main.tf ├── outputs.tf ├── providers.tf ├── variables.tf ├── mcpengine-proxy/ ├── .dockerignore ├── Dockerfile ├── README.md ├── auth.go ├── auth_test.go ├── cmd/ ├── main.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── http_test.go ``` ## /.git-blame-ignore-revs ```git-blame-ignore-revs path="/.git-blame-ignore-revs" ``` ## /.github/ISSUE_TEMPLATE/bug_report.md --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ## /.github/ISSUE_TEMPLATE/feature_request.md --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ## /.github/workflows/check-lock.yml ```yml path="/.github/workflows/check-lock.yml" name: Check uv.lock on: pull_request: paths: - "pyproject.toml" - "uv.lock" push: paths: - "pyproject.toml" - "uv.lock" jobs: check-lock: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install uv run: | curl -LsSf https://astral.sh/uv/install.sh | sh echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Check uv.lock is up to date run: uv lock --check ``` ## /.github/workflows/go-checks.yaml ```yaml path="/.github/workflows/go-checks.yaml" name: Run Go Tests on: workflow_call: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: go-version: '1.23' - name: Install dependencies run: go mod download working-directory: ./mcpengine-proxy/ - name: Run tests run: go test -v ./... working-directory: ./mcpengine-proxy/ ``` ## /.github/workflows/main-checks.yml ```yml path="/.github/workflows/main-checks.yml" name: Main branch checks on: push: branches: - main - "v*.*.*" tags: - "v*.*.*" jobs: checks: uses: ./.github/workflows/shared.yml ``` ## /.github/workflows/publish-docs-manually.yml ```yml path="/.github/workflows/publish-docs-manually.yml" name: Publish Docs manually on: workflow_dispatch: jobs: docs-publish: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 - name: Configure Git Credentials run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - uses: actions/cache@v4 with: key: mkdocs-material-${{ env.cache_id }} path: .cache restore-keys: | mkdocs-material- - run: uv sync --frozen --group docs - run: uv run --no-sync mkdocs gh-deploy --force ``` ## /.github/workflows/publish-pypi.yml ```yml path="/.github/workflows/publish-pypi.yml" name: Publishing on: release: types: [published] jobs: release-build: name: Build distribution runs-on: ubuntu-latest needs: [checks] steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true - name: Set up Python 3.12 run: uv python install 3.12 - name: Build run: uv build - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: release-dists path: dist/ checks: uses: ./.github/workflows/shared.yml pypi-publish: name: Upload release to PyPI runs-on: ubuntu-latest environment: release needs: - release-build permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - name: Retrieve release distributions uses: actions/download-artifact@v4 with: name: release-dists path: dist/ - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 docs-publish: runs-on: ubuntu-latest needs: ["pypi-publish"] permissions: contents: write steps: - uses: actions/checkout@v4 - name: Configure Git Credentials run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - uses: actions/cache@v4 with: key: mkdocs-material-${{ env.cache_id }} path: .cache restore-keys: | mkdocs-material- - run: uv sync --frozen --group docs - run: uv run --no-sync mkdocs gh-deploy --force ``` ## /.github/workflows/pull-request-checks.yml ```yml path="/.github/workflows/pull-request-checks.yml" name: Pull request checks on: pull_request: jobs: checks: uses: ./.github/workflows/shared.yml go-checks: uses: ./.github/workflows/go-checks.yaml ``` ## /.github/workflows/shared.yml ```yml path="/.github/workflows/shared.yml" name: Shared Checks on: workflow_call: jobs: format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true - name: Install the project run: uv sync --frozen --all-extras --dev --python 3.12 - name: Run ruff format check run: uv run --no-sync ruff check . typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true - name: Install the project run: uv sync --frozen --all-extras --dev --python 3.12 - name: Run pyright run: uv run --no-sync pyright test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true - name: Install the project run: uv sync --frozen --all-extras --dev --python ${{ matrix.python-version }} - name: Run pytest run: uv run --no-sync pytest ``` ## /.gitignore ```gitignore path="/.gitignore" .DS_Store scratch/ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # vscode .vscode/ **/CLAUDE.local.md ``` ## /.pre-commit-config.yaml ```yaml path="/.pre-commit-config.yaml" fail_fast: true repos: - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier types_or: [yaml, json5] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.1 hooks: - id: ruff-format - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: local hooks: - id: uv-lock-check name: Check uv.lock is up to date entry: uv lock --check language: system files: ^(pyproject\.toml|uv\.lock)$ pass_filenames: false ``` ## /CLAUDE.md # Development Guidelines This document contains critical information about working with this codebase. Follow these guidelines precisely. ## Core Development Rules 1. Package Management - ONLY use uv, NEVER pip - Installation: `uv add package` - Running tools: `uv run tool` - Upgrading: `uv add --dev package --upgrade-package package` - FORBIDDEN: `uv pip install`, `@latest` syntax 2. Code Quality - Type hints required for all code - Public APIs must have docstrings - Functions must be focused and small - Follow existing patterns exactly - Line length: 88 chars maximum 3. Testing Requirements - Framework: `uv run pytest` - Async testing: use anyio, not asyncio - Coverage: test edge cases and errors - New features require tests - Bug fixes require regression tests - For commits fixing bugs or adding features based on user reports add: ```bash git commit --trailer "Reported-by:" ``` Where `` is the name of the user. - For commits related to a Github issue, add ```bash git commit --trailer "Github-Issue:#" ``` - NEVER ever mention a `co-authored-by` or similar aspects. In particular, never mention the tool used to create the commit message or PR. ## Pull Requests - Create a detailed message of what changed. Focus on the high level description of the problem it tries to solve, and how it is solved. Don't go into the specifics of the code unless it adds clarity. - Always add `simba-git` as reviewer. - NEVER ever mention a `co-authored-by` or similar aspects. In particular, never mention the tool used to create the commit message or PR. ## Python Tools ## Code Formatting 1. Ruff - Format: `uv run ruff format .` - Check: `uv run ruff check .` - Fix: `uv run ruff check . --fix` - Critical issues: - Line length (88 chars) - Import sorting (I001) - Unused imports - Line wrapping: - Strings: use parentheses - Function calls: multi-line with proper indent - Imports: split into multiple lines 2. Type Checking - Tool: `uv run pyright` - Requirements: - Explicit None checks for Optional - Type narrowing for strings - Version warnings can be ignored if checks pass 3. Pre-commit - Config: `.pre-commit-config.yaml` - Runs: on git commit - Tools: Prettier (YAML/JSON), Ruff (Python) - Ruff updates: - Check PyPI versions - Update config rev - Commit config first ## Error Resolution 1. CI Failures - Fix order: 1. Formatting 2. Type errors 3. Linting - Type errors: - Get full line context - Check Optional types - Add type narrowing - Verify function signatures 2. Common Issues - Line length: - Break strings with parentheses - Multi-line function calls - Split imports - Types: - Add None checks - Narrow string types - Match existing patterns 3. Best Practices - Check git status before commits - Run formatters before type checks - Keep changes minimal - Follow existing patterns - Document public APIs - Test thoroughly ## /CODE_OF_CONDUCT.md # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hello@featureform.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ## /CONTRIBUTING.md # Contributing Thank you for your interest in contributing to the MCPEngine Python SDK! This document provides guidelines and instructions for contributing. ## Development Setup 1. Make sure you have Python 3.10+ installed 2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) 3. Fork the repository 4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git` 5. Install dependencies: ```bash uv sync --frozen --all-extras --dev ``` ## Development Workflow 1. Choose the correct branch for your changes: - For bug fixes to a released version: use the latest release branch (e.g. v1.1.x for 1.1.3) - For new features: use the main branch (which will become the next minor/major version) - If unsure, ask in an issue first 2. Create a new branch from your chosen base branch 3. Make your changes 4. Ensure tests pass: ```bash uv run pytest ``` 5. Run type checking: ```bash uv run pyright ``` 6. Run linting: ```bash uv run ruff check . uv run ruff format . ``` 7. Submit a pull request to the same branch you branched from ## Code Style - We use `ruff` for linting and formatting - Follow PEP 8 style guidelines - Add type hints to all functions - Include docstrings for public APIs ## Pull Request Process 1. Update documentation as needed 2. Add tests for new functionality 3. Ensure CI passes 4. Maintainers will review your code 5. Address review feedback ## Code of Conduct Please note that this project is released with a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. ## License By contributing, you agree that your contributions will be licensed under the MIT License. ## /LICENSE ``` path="/LICENSE" MIT License Copyright (c) 2024 Anthropic, PBC Copyright (c) 2025 Featureform, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ## /README.md # MCPEngine **Production-Grade Implementation of the Model Context Protocol (MCP)** MCPEngine Logo ## Overview **MCPEngine** is a production-grade, HTTP-first implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). It provides a secure, scalable, and modern framework for exposing data, tools, and prompts to Large Language Models (LLMs) via MCP. We believe MCP can be the "**REST for LLMs**," enabling any application (Slack, Gmail, GitHub, etc.) to expose a standardized endpoint that LLMs can access without custom-coded integrations. **MCPEngine** is our contribution to making MCP robust enough for modern, cloud-native use cases. ## Key Features - **Built-in OAuth** with Okta, Keycloak, Google SSO, etc. - **HTTP-first** design (SSE instead of just stdio) - **Scope-based Authorization** for tools, resources, and prompts - **Seamless bridging** for LLM hosts (like Claude Desktop) via a local proxy - **Full backwards-compatibility** with FastMCP and the official MCP SDK ## Architecture MCPEngine uses a proxy-based architecture to integrate with LLM hosts like Claude Desktop: ``` ┌───────────────┐ stdio ┌─────────────────┐ HTTP/SSE ┌───────────────┐ │ Claude Host ├───────────────► MCPProxy Local ├──────────────────► MCPEngine │ │ │ │ │ │ Server │ │ ◄───────────────┤ (runs locally) ◄──────────────────┬┤ (remote) │ └───────────────┘ └─────────────────┘ OAuth 2.1 │└───────────────┘ │ ┌────────────┴───────────┐ │ Identity Provider │ │ (Okta, Keycloak, etc.) │ └────────────────────────┘ ``` This architecture provides several advantages: 1. **Seamless integration** - Claude sees a local stdio-based process 2. **Security** - The proxy handles OAuth authentication flows 3. **Scalability** - The MCPEngine server can run anywhere (cloud, on-prem) 4. **Separation of concerns** - Authentication is handled independently from your business logic ## Installation ```bash uv add "mcpengine[cli]" # or pip install "mcpengine[cli]" ``` Once installed, you can run the CLI tools: ```bash mcpengine --help ``` ## Quickstart ### Create a Server ```python # server.py from mcpengine import MCPEngine mcp = MCPEngine("Demo") @mcp.tool() def add(a: int, b: int) -> int: return a + b @mcp.resource("greeting://{name}") def get_greeting(name: str) -> str: return f"Hello, {name}!" ``` ### Claude Desktop Integration If your server is at , you can start the proxy locally: ```bash mcpengine proxy http://localhost:8000/sse ``` Claude Desktop sees a local stdio server, while the proxy handles any necessary OAuth or SSE traffic automatically. ## Core Concepts ### Authentication & Authorization Enable OAuth and scopes: ```python from mcpengine import MCPEngine, Context from mcpengine.server.auth.providers.config import IdpConfig mcp = MCPEngine( "SecureDemo", idp_config=IdpConfig( issuer_url="https://your-idp.example.com/realms/some-realm", ), ) @mcp.auth(scopes=["calc:read"]) @mcp.tool() def add(a: int, b: int, ctx: Context) -> int: ctx.info(f"User {ctx.user_id} with roles {ctx.roles} called add.") return a + b ``` Any attempt to call `add` requires the user to have `calc:read` scope. Without it, the server returns 401 Unauthorized, prompting a login flow if used via the proxy. ### Resources `@mcp.resource("uri")`: Provide read-only context for LLMs, like a GET endpoint. ```python from mcpengine import MCPEngine mcp = MCPEngine("Demo") @mcp.resource("config://app") def get_config() -> str: return "Configuration Data" ``` ### Tools `@mcp.tool()`: LLM-invokable functions. They can have side effects or perform computations. ```python from mcpengine import MCPEngine mcp = MCPEngine("Demo") @mcp.tool() def send_email(to: str, body: str): return "Email Sent!" ``` ### Prompts `@mcp.prompt()`: Reusable conversation templates. ```python from mcpengine import MCPEngine mcp = MCPEngine("Demo") @mcp.prompt() def debug_prompt(error_msg: str): return f"Debug: {error_msg}" ``` ### Images Return images as first-class data: ```python from mcpengine import MCPEngine, Image mcp = MCPEngine("Demo") @mcp.tool() def thumbnail(path: str) -> Image: # ... function body omitted pass ``` ### Context Each request has a Context: - `ctx.user_id`: Authenticated user id - `ctx.user_name`: Authenticated user name - `ctx.roles`: User scopes/roles - `ctx.info(...)`: Logging - `ctx.read_resource(...)`: Access other resources ## Example Implementations ### SQLite Explorer ```python import sqlite3 from mcpengine import MCPEngine, Context from mcpengine.server.auth.providers.config import IdpConfig mcp = MCPEngine( "SQLiteExplorer", idp_config=IdpConfig( issuer_url="https://your-idp.example.com/realms/some-realm", ), ) @mcp.auth(scopes=["database:read"]) @mcp.tool() def query_db(sql: str, ctx: Context) -> str: conn = sqlite3.connect("data.db") try: rows = conn.execute(sql).fetchall() ctx.info(f"User {ctx.user.id} executed query: {sql}") return str(rows) except Exception as e: return f"Error: {str(e)}" ``` ### Echo Server ```python from mcpengine import MCPEngine mcp = MCPEngine("Demo") @mcp.resource("echo://{msg}") def echo_resource(msg: str): return f"Resource echo: {msg}" @mcp.tool() def echo_tool(msg: str): return f"Tool echo: {msg}" ``` ## Smack - Message Storage Example MCPEngine Smack Demo Smack is a simple messaging service example with PostgreSQL storage that demonstrates MCPEngine's capabilities with OAuth 2.1 authentication. ### Quick Start 1. Start the service using Docker Compose: ```bash git clone https://github.com/featureform/mcp-engine.git cd mcp-engine/examples/servers/smack docker-compose up --build ``` 2. Using Claude Desktop Configure Claude Desktop to use Smack: Manually: ```bash touch ~/Library/Application\ Support/Claude/claude_desktop_config.json ``` Add to the file: ```json { "mcpServers": { "smack_mcp_server": { "command": "bash", "args": [ "docker attach mcpengine_proxy || docker run --rm -i --net=host --name mcpengine_proxy featureformcom/mcpengine-proxy -host=http://localhost:8000 -debug -client_id=optional -client_secret=optional", ] } } } ``` Via CLI: ```bash mcpengine proxy http://localhost:8000 ``` Smack provides two main tools: - `list_messages()`: Retrieves all messages - `post_message(message: str)`: Posts a new message For more details, see the [Smack example code](https://github.com/featureform/mcp-engine/tree/main/examples/servers/smack). ## Roadmap - Advanced Auth Flows - Service Discovery - Fine-Grained Authorization - Observability & Telemetry - Ongoing FastMCP Compatibility ## Contributing We welcome feedback, issues, and pull requests. If you'd like to shape MCP's future, open an issue or propose changes on [GitHub](https://github.com/featureform/mcp-engine). We actively maintain MCPEngine to align with real-world enterprise needs. ## Community Join our discussion on [Slack](https://join.slack.com/t/featureform-community/shared_invite/zt-xhqp2m4i-JOCaN1vRN2NDXSVif10aQg?mc_cid=80bdc03b3b&mc_eid=UNIQID) to share feedback, propose features, or collaborate. ## License Licensed under the MIT License. See LICENSE for details. ## /RELEASE.md # Release Process ## Bumping Dependencies 1. Change dependency version in `pyproject.toml` 2. Upgrade lock with `uv lock --resolution lowest-direct` ## Major or Minor Release Create a GitHub release via UI with the tag being `vX.Y.Z` where `X.Y.Z` is the version, and the release title being the same. Then ask someone to review the release. The package version will be set automatically from the tag. ## /SECURITY.md # Security Policy Thank you for helping us keep the SDKs and systems they interact with secure. ## Reporting Security Issues This SDK is maintained by [Featureform](https://featureform.com/) as part of the Model Context Protocol project. The security of our systems and user data is Featureform's top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. ## /assets/logo.png Binary file available at https://raw.githubusercontent.com/featureform/mcp-engine/refs/heads/main/assets/logo.png ## /assets/mcpengine-smack-demo.gif Binary file available at https://raw.githubusercontent.com/featureform/mcp-engine/refs/heads/main/assets/mcpengine-smack-demo.gif ## /docs/api.md ::: mcpengine ## /docs/index.md # MCP Server This is the MCPEngine Server implementation in Python. It only contains the [API Reference](api.md) for the time being. ## /examples/README.md # Python SDK Examples This folders aims to provide simple examples of using the Python SDK. Please refer to the [servers repository](https://github.com/modelcontextprotocol/servers) for real-world servers. ## /examples/clients/simple-chatbot/.python-version ```python-version path="/examples/clients/simple-chatbot/.python-version" 3.10 ``` ## /examples/clients/simple-chatbot/README.MD # MCP Simple Chatbot This example demonstrates how to integrate the Model Context Protocol (MCP) into a simple CLI chatbot. The implementation showcases MCP's flexibility by supporting multiple tools through MCP servers and is compatible with any LLM provider that follows OpenAI API standards. ## Requirements - Python 3.10 - `python-dotenv` - `requests` - `mcpengine` - `uvicorn` ## Installation 1. **Install the dependencies:** ```bash pip install -r requirements.txt ``` 2. **Set up environment variables:** Create a `.env` file in the root directory and add your API key: ```plaintext LLM_API_KEY=your_api_key_here ``` 3. **Configure servers:** The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers. Here's an example: ```json { "mcpServers": { "sqlite": { "command": "uvx", "args": ["mcp-server-sqlite", "--db-path", "./test.db"] }, "puppeteer": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-puppeteer"] } } } ``` Environment variables are supported as well. Pass them as you would with the Claude Desktop App. Example: ```json { "mcpServers": { "server_name": { "command": "uvx", "args": ["mcp-server-name", "--additional-args"], "env": { "API_KEY": "your_api_key_here" } } } } ``` ## Usage 1. **Run the client:** ```bash python main.py ``` 2. **Interact with the assistant:** The assistant will automatically detect available tools and can respond to queries based on the tools provided by the configured servers. 3. **Exit the session:** Type `quit` or `exit` to end the session. ## Architecture - **Tool Discovery**: Tools are automatically discovered from configured servers. - **System Prompt**: Tools are dynamically included in the system prompt, allowing the LLM to understand available capabilities. - **Server Integration**: Supports any MCP-compatible server, tested with various server implementations including Uvicorn and Node.js. ### Class Structure - **Configuration**: Manages environment variables and server configurations - **Server**: Handles MCP server initialization, tool discovery, and execution - **Tool**: Represents individual tools with their properties and formatting - **LLMClient**: Manages communication with the LLM provider - **ChatSession**: Orchestrates the interaction between user, LLM, and tools ### Logic Flow 1. **Tool Integration**: - Tools are dynamically discovered from MCP servers - Tool descriptions are automatically included in system prompt - Tool execution is handled through standardized MCP protocol 2. **Runtime Flow**: - User input is received - Input is sent to LLM with context of available tools - LLM response is parsed: - If it's a tool call → execute tool and return result - If it's a direct response → return to user - Tool results are sent back to LLM for interpretation - Final response is presented to user ## /examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example ```example path="/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example" LLM_API_KEY=gsk_1234567890 ``` ## /examples/clients/simple-chatbot/mcp_simple_chatbot/main.py ```py path="/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py" import asyncio import json import logging import os import shutil from contextlib import AsyncExitStack from typing import Any import httpx from dotenv import load_dotenv from mcpengine import ClientSession, StdioServerParameters from mcpengine.client.stdio import stdio_client # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) class Configuration: """Manages configuration and environment variables for the MCP client.""" def __init__(self) -> None: """Initialize configuration with environment variables.""" self.load_env() self.api_key = os.getenv("LLM_API_KEY") @staticmethod def load_env() -> None: """Load environment variables from .env file.""" load_dotenv() @staticmethod def load_config(file_path: str) -> dict[str, Any]: """Load server configuration from JSON file. Args: file_path: Path to the JSON configuration file. Returns: Dict containing server configuration. Raises: FileNotFoundError: If configuration file doesn't exist. JSONDecodeError: If configuration file is invalid JSON. """ with open(file_path, "r") as f: return json.load(f) @property def llm_api_key(self) -> str: """Get the LLM API key. Returns: The API key as a string. Raises: ValueError: If the API key is not found in environment variables. """ if not self.api_key: raise ValueError("LLM_API_KEY not found in environment variables") return self.api_key class Server: """Manages MCP server connections and tool execution.""" def __init__(self, name: str, config: dict[str, Any]) -> None: self.name: str = name self.config: dict[str, Any] = config self.stdio_context: Any | None = None self.session: ClientSession | None = None self._cleanup_lock: asyncio.Lock = asyncio.Lock() self.exit_stack: AsyncExitStack = AsyncExitStack() async def initialize(self) -> None: """Initialize the server connection.""" command = ( shutil.which("npx") if self.config["command"] == "npx" else self.config["command"] ) if command is None: raise ValueError("The command must be a valid string and cannot be None.") server_params = StdioServerParameters( command=command, args=self.config["args"], env={**os.environ, **self.config["env"]} if self.config.get("env") else None, ) try: stdio_transport = await self.exit_stack.enter_async_context( stdio_client(server_params) ) read, write = stdio_transport session = await self.exit_stack.enter_async_context( ClientSession(read, write) ) await session.initialize() self.session = session except Exception as e: logging.error(f"Error initializing server {self.name}: {e}") await self.cleanup() raise async def list_tools(self) -> list[Any]: """List available tools from the server. Returns: A list of available tools. Raises: RuntimeError: If the server is not initialized. """ if not self.session: raise RuntimeError(f"Server {self.name} not initialized") tools_response = await self.session.list_tools() tools = [] for item in tools_response: if isinstance(item, tuple) and item[0] == "tools": for tool in item[1]: tools.append(Tool(tool.name, tool.description, tool.inputSchema)) return tools async def execute_tool( self, tool_name: str, arguments: dict[str, Any], retries: int = 2, delay: float = 1.0, ) -> Any: """Execute a tool with retry mechanism. Args: tool_name: Name of the tool to execute. arguments: Tool arguments. retries: Number of retry attempts. delay: Delay between retries in seconds. Returns: Tool execution result. Raises: RuntimeError: If server is not initialized. Exception: If tool execution fails after all retries. """ if not self.session: raise RuntimeError(f"Server {self.name} not initialized") attempt = 0 while attempt < retries: try: logging.info(f"Executing {tool_name}...") result = await self.session.call_tool(tool_name, arguments) return result except Exception as e: attempt += 1 logging.warning( f"Error executing tool: {e}. Attempt {attempt} of {retries}." ) if attempt < retries: logging.info(f"Retrying in {delay} seconds...") await asyncio.sleep(delay) else: logging.error("Max retries reached. Failing.") raise async def cleanup(self) -> None: """Clean up server resources.""" async with self._cleanup_lock: try: await self.exit_stack.aclose() self.session = None self.stdio_context = None except Exception as e: logging.error(f"Error during cleanup of server {self.name}: {e}") class Tool: """Represents a tool with its properties and formatting.""" def __init__( self, name: str, description: str, input_schema: dict[str, Any] ) -> None: self.name: str = name self.description: str = description self.input_schema: dict[str, Any] = input_schema def format_for_llm(self) -> str: """Format tool information for LLM. Returns: A formatted string describing the tool. """ args_desc = [] if "properties" in self.input_schema: for param_name, param_info in self.input_schema["properties"].items(): arg_desc = ( f"- {param_name}: {param_info.get('description', 'No description')}" ) if param_name in self.input_schema.get("required", []): arg_desc += " (required)" args_desc.append(arg_desc) return f""" Tool: {self.name} Description: {self.description} Arguments: {chr(10).join(args_desc)} """ class LLMClient: """Manages communication with the LLM provider.""" def __init__(self, api_key: str) -> None: self.api_key: str = api_key def get_response(self, messages: list[dict[str, str]]) -> str: """Get a response from the LLM. Args: messages: A list of message dictionaries. Returns: The LLM's response as a string. Raises: httpx.RequestError: If the request to the LLM fails. """ url = "https://api.groq.com/openai/v1/chat/completions" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", } payload = { "messages": messages, "model": "llama-3.2-90b-vision-preview", "temperature": 0.7, "max_tokens": 4096, "top_p": 1, "stream": False, "stop": None, } try: with httpx.Client() as client: response = client.post(url, headers=headers, json=payload) response.raise_for_status() data = response.json() return data["choices"][0]["message"]["content"] except httpx.RequestError as e: error_message = f"Error getting LLM response: {str(e)}" logging.error(error_message) if isinstance(e, httpx.HTTPStatusError): status_code = e.response.status_code logging.error(f"Status code: {status_code}") logging.error(f"Response details: {e.response.text}") return ( f"I encountered an error: {error_message}. " "Please try again or rephrase your request." ) class ChatSession: """Orchestrates the interaction between user, LLM, and tools.""" def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: self.servers: list[Server] = servers self.llm_client: LLMClient = llm_client async def cleanup_servers(self) -> None: """Clean up all servers properly.""" cleanup_tasks = [] for server in self.servers: cleanup_tasks.append(asyncio.create_task(server.cleanup())) if cleanup_tasks: try: await asyncio.gather(*cleanup_tasks, return_exceptions=True) except Exception as e: logging.warning(f"Warning during final cleanup: {e}") async def process_llm_response(self, llm_response: str) -> str: """Process the LLM response and execute tools if needed. Args: llm_response: The response from the LLM. Returns: The result of tool execution or the original response. """ import json try: tool_call = json.loads(llm_response) if "tool" in tool_call and "arguments" in tool_call: logging.info(f"Executing tool: {tool_call['tool']}") logging.info(f"With arguments: {tool_call['arguments']}") for server in self.servers: tools = await server.list_tools() if any(tool.name == tool_call["tool"] for tool in tools): try: result = await server.execute_tool( tool_call["tool"], tool_call["arguments"] ) if isinstance(result, dict) and "progress" in result: progress = result["progress"] total = result["total"] percentage = (progress / total) * 100 logging.info( f"Progress: {progress}/{total} ({percentage:.1f}%)" ) return f"Tool execution result: {result}" except Exception as e: error_msg = f"Error executing tool: {str(e)}" logging.error(error_msg) return error_msg return f"No server found with tool: {tool_call['tool']}" return llm_response except json.JSONDecodeError: return llm_response async def start(self) -> None: """Main chat session handler.""" try: for server in self.servers: try: await server.initialize() except Exception as e: logging.error(f"Failed to initialize server: {e}") await self.cleanup_servers() return all_tools = [] for server in self.servers: tools = await server.list_tools() all_tools.extend(tools) tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) system_message = ( "You are a helpful assistant with access to these tools:\n\n" f"{tools_description}\n" "Choose the appropriate tool based on the user's question. " "If no tool is needed, reply directly.\n\n" "IMPORTANT: When you need to use a tool, you must ONLY respond with " "the exact JSON object format below, nothing else:\n" "{\n" ' "tool": "tool-name",\n' ' "arguments": {\n' ' "argument-name": "value"\n' " }\n" "}\n\n" "After receiving a tool's response:\n" "1. Transform the raw data into a natural, conversational response\n" "2. Keep responses concise but informative\n" "3. Focus on the most relevant information\n" "4. Use appropriate context from the user's question\n" "5. Avoid simply repeating the raw data\n\n" "Please use only the tools that are explicitly defined above." ) messages = [{"role": "system", "content": system_message}] while True: try: user_input = input("You: ").strip().lower() if user_input in ["quit", "exit"]: logging.info("\nExiting...") break messages.append({"role": "user", "content": user_input}) llm_response = self.llm_client.get_response(messages) logging.info("\nAssistant: %s", llm_response) result = await self.process_llm_response(llm_response) if result != llm_response: messages.append({"role": "assistant", "content": llm_response}) messages.append({"role": "system", "content": result}) final_response = self.llm_client.get_response(messages) logging.info("\nFinal response: %s", final_response) messages.append( {"role": "assistant", "content": final_response} ) else: messages.append({"role": "assistant", "content": llm_response}) except KeyboardInterrupt: logging.info("\nExiting...") break finally: await self.cleanup_servers() async def main() -> None: """Initialize and run the chat session.""" config = Configuration() server_config = config.load_config("servers_config.json") servers = [ Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items() ] llm_client = LLMClient(config.llm_api_key) chat_session = ChatSession(servers, llm_client) await chat_session.start() if __name__ == "__main__": asyncio.run(main()) ``` ## /examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt python-dotenv>=1.0.0 requests>=2.31.0 mcp>=1.0.0 uvicorn>=0.32.1 ## /examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json ```json path="/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json" { "mcpServers": { "sqlite": { "command": "uvx", "args": ["mcp-server-sqlite", "--db-path", "./test.db"] }, "puppeteer": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-puppeteer"] } } } ``` ## /examples/clients/simple-chatbot/mcp_simple_chatbot/test.db Binary file available at https://raw.githubusercontent.com/featureform/mcp-engine/refs/heads/main/examples/clients/simple-chatbot/mcp_simple_chatbot/test.db ## /examples/clients/simple-chatbot/pyproject.toml ```toml path="/examples/clients/simple-chatbot/pyproject.toml" [project] name = "mcp-simple-chatbot" version = "0.1.0" description = "A simple CLI chatbot using the Model Context Protocol (MCP)" readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Edoardo Cilia" }] keywords = ["mcp", "mcpengine", "llm", "chatbot", "cli"] license = { text = "MIT" } classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] dependencies = [ "python-dotenv>=1.0.0", "requests>=2.31.0", "mcp>=1.0.0", "uvicorn>=0.32.1" ] [project.scripts] mcp-simple-chatbot = "mcp_simple_chatbot.client:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["mcp_simple_chatbot"] [tool.pyright] include = ["mcp_simple_chatbot"] venvPath = "." venv = ".venv" [tool.ruff.lint] select = ["E", "F", "I"] ignore = [] [tool.ruff] line-length = 88 target-version = "py310" [tool.uv] dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] ``` ## /examples/clients/simple-chatbot/uv.lock ```lock path="/examples/clients/simple-chatbot/uv.lock" version = 1 requires-python = ">=3.10" [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] [[package]] name = "anyio" version = "4.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] [[package]] name = "certifi" version = "2024.12.14" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } wheels = [ { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, ] [[package]] name = "charset-normalizer" version = "3.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } wheels = [ { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, ] [[package]] name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "platform_system == 'Windows'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] name = "exceptiongroup" version = "1.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] [[package]] name = "h11" version = "0.14.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } wheels = [ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, ] [[package]] name = "httpcore" version = "1.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } wheels = [ { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, ] [[package]] name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [[package]] name = "httpx-sse" version = "0.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } wheels = [ { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] [[package]] name = "iniconfig" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] [[package]] name = "mcp" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx" }, { name = "httpx-sse" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "sse-starlette" }, { name = "starlette" }, { name = "uvicorn" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/a5/b08dc846ebedae9f17ced878e6975826e90e448cd4592f532f6a88a925a7/mcp-1.2.0.tar.gz", hash = "sha256:2b06c7ece98d6ea9e6379caa38d74b432385c338fb530cb82e2c70ea7add94f5", size = 102973 } wheels = [ { url = "https://files.pythonhosted.org/packages/af/84/fca78f19ac8ce6c53ba416247c71baa53a9e791e98d3c81edbc20a77d6d1/mcp-1.2.0-py3-none-any.whl", hash = "sha256:1d0e77d8c14955a5aea1f5aa1f444c8e531c09355c829b20e42f7a142bc0755f", size = 66468 }, ] [[package]] name = "mcp-simple-chatbot" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "mcp" }, { name = "python-dotenv" }, { name = "requests" }, { name = "uvicorn" }, ] [package.dev-dependencies] dev = [ { name = "pyright" }, { name = "pytest" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "mcp", specifier = ">=1.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "requests", specifier = ">=2.31.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] [package.metadata.requires-dev] dev = [ { name = "pyright", specifier = ">=1.1.379" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "ruff", specifier = ">=0.6.9" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] [[package]] name = "packaging" version = "24.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] [[package]] name = "pydantic" version = "2.10.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287 } wheels = [ { url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426 }, ] [[package]] name = "pydantic-core" version = "2.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, ] [[package]] name = "pydantic-settings" version = "2.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 } wheels = [ { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 }, ] [[package]] name = "pyright" version = "1.1.392.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/df/3c6f6b08fba7ccf49b114dfc4bb33e25c299883fd763f93fad47ef8bc58d/pyright-1.1.392.post0.tar.gz", hash = "sha256:3b7f88de74a28dcfa90c7d90c782b6569a48c2be5f9d4add38472bdaac247ebd", size = 3789911 } wheels = [ { url = "https://files.pythonhosted.org/packages/e7/b1/a18de17f40e4f61ca58856b9ef9b0febf74ff88978c3f7776f910071f567/pyright-1.1.392.post0-py3-none-any.whl", hash = "sha256:252f84458a46fa2f0fd4e2f91fc74f50b9ca52c757062e93f6c250c0d8329eb2", size = 5595487 }, ] [[package]] name = "pytest" version = "8.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] [[package]] name = "python-dotenv" version = "1.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] [[package]] name = "requests" version = "2.32.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] [[package]] name = "ruff" version = "0.9.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/80/63/77ecca9d21177600f551d1c58ab0e5a0b260940ea7312195bd2a4798f8a8/ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0", size = 3553799 } wheels = [ { url = "https://files.pythonhosted.org/packages/af/b9/0e168e4e7fb3af851f739e8f07889b91d1a33a30fca8c29fa3149d6b03ec/ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347", size = 11652408 }, { url = "https://files.pythonhosted.org/packages/2c/22/08ede5db17cf701372a461d1cb8fdde037da1d4fa622b69ac21960e6237e/ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00", size = 11587553 }, { url = "https://files.pythonhosted.org/packages/42/05/dedfc70f0bf010230229e33dec6e7b2235b2a1b8cbb2a991c710743e343f/ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4", size = 11020755 }, { url = "https://files.pythonhosted.org/packages/df/9b/65d87ad9b2e3def67342830bd1af98803af731243da1255537ddb8f22209/ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d", size = 11826502 }, { url = "https://files.pythonhosted.org/packages/93/02/f2239f56786479e1a89c3da9bc9391120057fc6f4a8266a5b091314e72ce/ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c", size = 11390562 }, { url = "https://files.pythonhosted.org/packages/c9/37/d3a854dba9931f8cb1b2a19509bfe59e00875f48ade632e95aefcb7a0aee/ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f", size = 12548968 }, { url = "https://files.pythonhosted.org/packages/fa/c3/c7b812bb256c7a1d5553433e95980934ffa85396d332401f6b391d3c4569/ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684", size = 13187155 }, { url = "https://files.pythonhosted.org/packages/bd/5a/3c7f9696a7875522b66aa9bba9e326e4e5894b4366bd1dc32aa6791cb1ff/ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d", size = 12704674 }, { url = "https://files.pythonhosted.org/packages/be/d6/d908762257a96ce5912187ae9ae86792e677ca4f3dc973b71e7508ff6282/ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df", size = 14529328 }, { url = "https://files.pythonhosted.org/packages/2d/c2/049f1e6755d12d9cd8823242fa105968f34ee4c669d04cac8cea51a50407/ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247", size = 12385955 }, { url = "https://files.pythonhosted.org/packages/91/5a/a9bdb50e39810bd9627074e42743b00e6dc4009d42ae9f9351bc3dbc28e7/ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e", size = 11810149 }, { url = "https://files.pythonhosted.org/packages/e5/fd/57df1a0543182f79a1236e82a79c68ce210efb00e97c30657d5bdb12b478/ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe", size = 11479141 }, { url = "https://files.pythonhosted.org/packages/dc/16/bc3fd1d38974f6775fc152a0554f8c210ff80f2764b43777163c3c45d61b/ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb", size = 12014073 }, { url = "https://files.pythonhosted.org/packages/47/6b/e4ca048a8f2047eb652e1e8c755f384d1b7944f69ed69066a37acd4118b0/ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a", size = 12435758 }, { url = "https://files.pythonhosted.org/packages/c2/40/4d3d6c979c67ba24cf183d29f706051a53c36d78358036a9cd21421582ab/ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145", size = 9796916 }, { url = "https://files.pythonhosted.org/packages/c3/ef/7f548752bdb6867e6939489c87fe4da489ab36191525fadc5cede2a6e8e2/ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5", size = 10773080 }, { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] [[package]] name = "sse-starlette" version = "2.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } wheels = [ { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, ] [[package]] name = "starlette" version = "0.45.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/4f/e1c9f4ec3dae67a94c9285ed275355d5f7cf0f3a5c34538c8ae5412af550/starlette-0.45.2.tar.gz", hash = "sha256:bba1831d15ae5212b22feab2f218bab6ed3cd0fc2dc1d4442443bb1ee52260e0", size = 2574026 } wheels = [ { url = "https://files.pythonhosted.org/packages/aa/ab/fe4f57c83620b39dfc9e7687ebad59129ff05170b99422105019d9a65eec/starlette-0.45.2-py3-none-any.whl", hash = "sha256:4daec3356fb0cb1e723a5235e5beaf375d2259af27532958e2d79df549dad9da", size = 71505 }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } wheels = [ { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] [[package]] name = "typing-extensions" version = "4.12.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] [[package]] name = "urllib3" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } wheels = [ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] [[package]] name = "uvicorn" version = "0.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } wheels = [ { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, ] ``` ## /examples/mcpengine/complex_inputs.py ```py path="/examples/mcpengine/complex_inputs.py" """ MCPEngine Complex inputs Example Demonstrates validation via pydantic with complex models. """ from typing import Annotated from pydantic import BaseModel, Field from mcpengine import MCPEngine mcp = MCPEngine("Shrimp Tank") class ShrimpTank(BaseModel): class Shrimp(BaseModel): name: Annotated[str, Field(max_length=10)] shrimp: list[Shrimp] @mcp.tool() def name_shrimp( tank: ShrimpTank, # You can use pydantic Field in function signatures for validation. extra_names: Annotated[list[str], Field(max_length=10)], ) -> list[str]: """List all shrimp names in the tank""" return [shrimp.name for shrimp in tank.shrimp] + extra_names ``` ## /examples/mcpengine/desktop.py ```py path="/examples/mcpengine/desktop.py" """ MCPEngine Desktop Example A simple example that exposes the desktop directory as a resource. """ from pathlib import Path from mcpengine import MCPEngine # Create server mcp = MCPEngine("Demo") @mcp.resource("dir://desktop") def desktop() -> list[str]: """List the files in the user's desktop""" desktop = Path.home() / "Desktop" return [str(f) for f in desktop.iterdir()] @mcp.tool() def add(a: int, b: int) -> int: """Add two numbers""" return a + b ``` ## /examples/mcpengine/echo.py ```py path="/examples/mcpengine/echo.py" """ MCPEngine Echo Server """ from mcpengine import MCPEngine # Create server mcp = MCPEngine("Echo Server") @mcp.tool() def echo_tool(text: str) -> str: """Echo the input text""" return text @mcp.resource("echo://static") def echo_resource() -> str: return "Echo!" @mcp.resource("echo://{text}") def echo_template(text: str) -> str: """Echo the input text""" return f"Echo: {text}" @mcp.prompt("echo") def echo_prompt(text: str) -> str: return text ``` ## /examples/mcpengine/memory.py ```py path="/examples/mcpengine/memory.py" # /// script # dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector"] # /// # uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector """ Recursive memory system inspired by the human brain's clustering of memories. Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient similarity search. """ import asyncio import math import os from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Annotated, Self import asyncpg import numpy as np from openai import AsyncOpenAI from pgvector.asyncpg import register_vector # Import register_vector from pydantic import BaseModel, Field from pydantic_ai import Agent from mcpengine import MCPEngine MAX_DEPTH = 5 SIMILARITY_THRESHOLD = 0.7 DECAY_FACTOR = 0.99 REINFORCEMENT_FACTOR = 1.1 DEFAULT_LLM_MODEL = "openai:gpt-4o" DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" mcp = MCPEngine( "memory", dependencies=[ "pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector", ], ) DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db" # reset memory with rm ~/.mcpengine/{USER}/memory/* PROFILE_DIR = ( Path.home() / ".mcpengine" / os.environ.get("USER", "anon") / "memory" ).resolve() PROFILE_DIR.mkdir(parents=True, exist_ok=True) def cosine_similarity(a: list[float], b: list[float]) -> float: a_array = np.array(a, dtype=np.float64) b_array = np.array(b, dtype=np.float64) return np.dot(a_array, b_array) / ( np.linalg.norm(a_array) * np.linalg.norm(b_array) ) async def do_ai[T]( user_prompt: str, system_prompt: str, result_type: type[T] | Annotated, deps=None, ) -> T: agent = Agent( DEFAULT_LLM_MODEL, system_prompt=system_prompt, result_type=result_type, ) result = await agent.run(user_prompt, deps=deps) return result.data @dataclass class Deps: openai: AsyncOpenAI pool: asyncpg.Pool async def get_db_pool() -> asyncpg.Pool: async def init(conn): await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") await register_vector(conn) pool = await asyncpg.create_pool(DB_DSN, init=init) return pool class MemoryNode(BaseModel): id: int | None = None content: str summary: str = "" importance: float = 1.0 access_count: int = 0 timestamp: float = Field( default_factory=lambda: datetime.now(timezone.utc).timestamp() ) embedding: list[float] @classmethod async def from_content(cls, content: str, deps: Deps): embedding = await get_embedding(content, deps) return cls(content=content, embedding=embedding) async def save(self, deps: Deps): async with deps.pool.acquire() as conn: if self.id is None: result = await conn.fetchrow( """ INSERT INTO memories (content, summary, importance, access_count, timestamp, embedding) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id """, self.content, self.summary, self.importance, self.access_count, self.timestamp, self.embedding, ) self.id = result["id"] else: await conn.execute( """ UPDATE memories SET content = $1, summary = $2, importance = $3, access_count = $4, timestamp = $5, embedding = $6 WHERE id = $7 """, self.content, self.summary, self.importance, self.access_count, self.timestamp, self.embedding, self.id, ) async def merge_with(self, other: Self, deps: Deps): self.content = await do_ai( f"{self.content}\n\n{other.content}", "Combine the following two texts into a single, coherent text.", str, deps, ) self.importance += other.importance self.access_count += other.access_count self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)] self.summary = await do_ai( self.content, "Summarize the following text concisely.", str, deps ) await self.save(deps) # Delete the merged node from the database if other.id is not None: await delete_memory(other.id, deps) def get_effective_importance(self): return self.importance * (1 + math.log(self.access_count + 1)) async def get_embedding(text: str, deps: Deps) -> list[float]: embedding_response = await deps.openai.embeddings.create( input=text, model=DEFAULT_EMBEDDING_MODEL, ) return embedding_response.data[0].embedding async def delete_memory(memory_id: int, deps: Deps): async with deps.pool.acquire() as conn: await conn.execute("DELETE FROM memories WHERE id = $1", memory_id) async def add_memory(content: str, deps: Deps): new_memory = await MemoryNode.from_content(content, deps) await new_memory.save(deps) similar_memories = await find_similar_memories(new_memory.embedding, deps) for memory in similar_memories: if memory.id != new_memory.id: await new_memory.merge_with(memory, deps) await update_importance(new_memory.embedding, deps) await prune_memories(deps) return f"Remembered: {content}" async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]: async with deps.pool.acquire() as conn: rows = await conn.fetch( """ SELECT id, content, summary, importance, access_count, timestamp, embedding FROM memories ORDER BY embedding <-> $1 LIMIT 5 """, embedding, ) memories = [ MemoryNode( id=row["id"], content=row["content"], summary=row["summary"], importance=row["importance"], access_count=row["access_count"], timestamp=row["timestamp"], embedding=row["embedding"], ) for row in rows ] return memories async def update_importance(user_embedding: list[float], deps: Deps): async with deps.pool.acquire() as conn: rows = await conn.fetch( "SELECT id, importance, access_count, embedding FROM memories" ) for row in rows: memory_embedding = row["embedding"] similarity = cosine_similarity(user_embedding, memory_embedding) if similarity > SIMILARITY_THRESHOLD: new_importance = row["importance"] * REINFORCEMENT_FACTOR new_access_count = row["access_count"] + 1 else: new_importance = row["importance"] * DECAY_FACTOR new_access_count = row["access_count"] await conn.execute( """ UPDATE memories SET importance = $1, access_count = $2 WHERE id = $3 """, new_importance, new_access_count, row["id"], ) async def prune_memories(deps: Deps): async with deps.pool.acquire() as conn: rows = await conn.fetch( """ SELECT id, importance, access_count FROM memories ORDER BY importance DESC OFFSET $1 """, MAX_DEPTH, ) for row in rows: await conn.execute("DELETE FROM memories WHERE id = $1", row["id"]) async def display_memory_tree(deps: Deps) -> str: async with deps.pool.acquire() as conn: rows = await conn.fetch( """ SELECT content, summary, importance, access_count FROM memories ORDER BY importance DESC LIMIT $1 """, MAX_DEPTH, ) result = "" for row in rows: effective_importance = row["importance"] * ( 1 + math.log(row["access_count"] + 1) ) summary = row["summary"] or row["content"] result += f"- {summary} (Importance: {effective_importance:.2f})\n" return result @mcp.tool() async def remember( contents: list[str] = Field( description="List of observations or memories to store" ), ): deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) try: return "\n".join( await asyncio.gather(*[add_memory(content, deps) for content in contents]) ) finally: await deps.pool.close() @mcp.tool() async def read_profile() -> str: deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) profile = await display_memory_tree(deps) await deps.pool.close() return profile async def initialize_database(): pool = await asyncpg.create_pool( "postgresql://postgres:postgres@localhost:54320/postgres" ) try: async with pool.acquire() as conn: await conn.execute(""" SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = 'memory_db' AND pid <> pg_backend_pid(); """) await conn.execute("DROP DATABASE IF EXISTS memory_db;") await conn.execute("CREATE DATABASE memory_db;") finally: await pool.close() pool = await asyncpg.create_pool(DB_DSN) try: async with pool.acquire() as conn: await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") await register_vector(conn) await conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id SERIAL PRIMARY KEY, content TEXT NOT NULL, summary TEXT, importance REAL NOT NULL, access_count INT NOT NULL, timestamp DOUBLE PRECISION NOT NULL, embedding vector(1536) NOT NULL ); CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories USING hnsw (embedding vector_l2_ops); """) finally: await pool.close() if __name__ == "__main__": asyncio.run(initialize_database()) ``` ## /examples/mcpengine/parameter_descriptions.py ```py path="/examples/mcpengine/parameter_descriptions.py" """ MCPEngine Example showing parameter descriptions """ from pydantic import Field from mcpengine import MCPEngine # Create server mcp = MCPEngine("Parameter Descriptions Server") @mcp.tool() def greet_user( name: str = Field(description="The name of the person to greet"), title: str = Field(description="Optional title like Mr/Ms/Dr", default=""), times: int = Field(description="Number of times to repeat the greeting", default=1), ) -> str: """Greet a user with optional title and repetition""" greeting = f"Hello {title + ' ' if title else ''}{name}!" return "\n".join([greeting] * times) ``` ## /examples/mcpengine/readme-quickstart.py ```py path="/examples/mcpengine/readme-quickstart.py" from mcpengine import MCPEngine # Create an MCP server mcp = MCPEngine("Demo") # Add an addition tool @mcp.tool() def add(a: int, b: int) -> int: """Add two numbers""" return a + b # Add a dynamic greeting resource @mcp.resource("greeting://{name}") def get_greeting(name: str) -> str: """Get a personalized greeting""" return f"Hello, {name}!" ``` ## /examples/mcpengine/screenshot.py ```py path="/examples/mcpengine/screenshot.py" """ MCPEngine Screenshot Example Give Claude a tool to capture and view screenshots. """ import io from mcpengine import MCPEngine from mcpengine.server.mcpengine.utilities.types import Image # Create server mcp = MCPEngine("Screenshot Demo", dependencies=["pyautogui", "Pillow"]) @mcp.tool() def take_screenshot() -> Image: """ Take a screenshot of the user's screen and return it as an image. Use this tool anytime the user wants you to look at something they're doing. """ import pyautogui buffer = io.BytesIO() # if the file exceeds ~1MB, it will be rejected by Claude screenshot = pyautogui.screenshot() screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True) return Image(data=buffer.getvalue(), format="jpeg") ``` ## /examples/mcpengine/simple_echo.py ```py path="/examples/mcpengine/simple_echo.py" """ MCPEngine Echo Server """ from mcpengine import MCPEngine # Create server mcp = MCPEngine("Echo Server") @mcp.tool() def echo(text: str) -> str: """Echo the input text""" return text ``` ## /examples/mcpengine/text_me.py ```py path="/examples/mcpengine/text_me.py" # /// script # dependencies = [] # /// """ MCPEngine Text Me Server -------------------------------- This defines a simple MCPEngine server that sends a text message to a phone number via https://surgemsg.com/. To run this example, create a `.env` file with the following values: SURGE_API_KEY=... SURGE_ACCOUNT_ID=... SURGE_MY_PHONE_NUMBER=... SURGE_MY_FIRST_NAME=... SURGE_MY_LAST_NAME=... Visit https://surgemsg.com/ and click "Get Started" to obtain these values. """ from typing import Annotated import httpx from pydantic import BeforeValidator from pydantic_settings import BaseSettings, SettingsConfigDict from mcpengine import MCPEngine class SurgeSettings(BaseSettings): model_config: SettingsConfigDict = SettingsConfigDict( env_prefix="SURGE_", env_file=".env" ) api_key: str account_id: str my_phone_number: Annotated[ str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v) ] my_first_name: str my_last_name: str # Create server mcp = MCPEngine("Text me") surge_settings = SurgeSettings() # type: ignore @mcp.tool(name="textme", description="Send a text message to me") def text_me(text_content: str) -> str: """Send a text message to a phone number via https://surgemsg.com/""" with httpx.Client() as client: response = client.post( "https://api.surgemsg.com/messages", headers={ "Authorization": f"Bearer {surge_settings.api_key}", "Surge-Account": surge_settings.account_id, "Content-Type": "application/json", }, json={ "body": text_content, "conversation": { "contact": { "first_name": surge_settings.my_first_name, "last_name": surge_settings.my_last_name, "phone_number": surge_settings.my_phone_number, } }, }, ) response.raise_for_status() return f"Message sent: {text_content}" ``` ## /examples/mcpengine/unicode_example.py ```py path="/examples/mcpengine/unicode_example.py" """ Example MCPEngine server that uses Unicode characters in various places to help test Unicode handling in tools and inspectors. """ from mcpengine import MCPEngine mcp = MCPEngine() @mcp.tool( description="🌟 A tool that uses various Unicode characters in its description: " "á é í ó ú ñ 漢字 🎉" ) def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str: """ A simple tool that demonstrates Unicode handling in: - Tool description (emojis, accents, CJK characters) - Parameter defaults (CJK characters) - Return values (Spanish punctuation, emojis) """ return f"{greeting}, {name}! 👋" @mcp.tool(description="🎨 Tool that returns a list of emoji categories") def list_emoji_categories() -> list[str]: """Returns a list of emoji categories with emoji examples.""" return [ "😀 Smileys & Emotion", "👋 People & Body", "🐶 Animals & Nature", "🍎 Food & Drink", "⚽ Activities", "🌍 Travel & Places", "💡 Objects", "❤️ Symbols", "🚩 Flags", ] @mcp.tool(description="🔤 Tool that returns text in different scripts") def multilingual_hello() -> str: """Returns hello in different scripts and writing systems.""" return "\n".join( [ "English: Hello!", "Spanish: ¡Hola!", "French: Bonjour!", "German: Grüß Gott!", "Russian: Привет!", "Greek: Γεια σας!", "Hebrew: !שָׁלוֹם", "Arabic: !مرحبا", "Hindi: नमस्ते!", "Chinese: 你好!", "Japanese: こんにちは!", "Korean: 안녕하세요!", "Thai: สวัสดี!", ] ) if __name__ == "__main__": mcp.run() ``` ## /examples/servers/lambda-weather/.dockerignore ```dockerignore path="/examples/servers/lambda-weather/.dockerignore" terraform .venv ``` ## /examples/servers/lambda-weather/.gitignore ```gitignore path="/examples/servers/lambda-weather/.gitignore" For a Terraform project, you'll want to ignore several types of files to keep your repository clean and secure. Here's what I'd recommend adding to your .gitignore file: # Local .terraform directories **/.terraform/* # .tfstate files *.tfstate *.tfstate.* # Crash log files crash.log crash.*.log # Exclude all .tfvars files, which might contain sensitive data *.tfvars *.tfvars.json # Ignore override files as they're usually used for local development override.tf override.tf.json *_override.tf *_override.tf.json # Ignore CLI configuration files .terraformrc terraform.rc # Ignore lock files - optional, some teams prefer to version these # .terraform.lock.hcl # Ignore plan output files tfplan *.tfplan # Ignore sensitive variable files *.auto.tfvars ``` ## /examples/servers/lambda-weather/Dockerfile ``` path="/examples/servers/lambda-weather/Dockerfile" # Copyright (c) 2025 Featureform, Inc. # # Licensed under the MIT License. See LICENSE file in the project root for full license information. FROM public.ecr.aws/lambda/python:3.12 # Set working directory in the container WORKDIR /var/task # Install uv RUN pip install --no-cache-dir uv # Copy application code COPY . . # Install dependencies using uv with --system flag RUN uv pip install --system --no-cache-dir . # Expose port for the server EXPOSE 8000 # Command to run the web server CMD ["weather.server.handler"] ``` ## /examples/servers/lambda-weather/README.md ## /examples/servers/lambda-weather/pyproject.toml ```toml path="/examples/servers/lambda-weather/pyproject.toml" [project] name = "lambda-weather" version = "0.1.0" description = "A simple MCP server exposing a weather tool, to be hosted on Lambda" readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Featureform, Inc." }] maintainers = [ { name = "Simba Khadder", email = "simba@featureform.com" }, { name = "Kamal Sadek", email = "kamal@featureform.com" }, { name = "Erik Eppel", email = "erik@featureform.com" }, { name = "Riddhi Bagadiaa", email = "riddhi@featureform.com" }, ] keywords = ["mcp", "llm", "automation", "web", "fetch"] license = { text = "MIT" } classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", ] dependencies = [ "httpx>=0.27.0", "mcpengine[lambda]>=0.3.0", ] [tool.hatch.metadata] allow-direct-references = true [project.scripts] weather = "weather.server:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["lambda-weather"] [tool.pyright] include = ["weather"] venvPath = "." venv = ".venv" [tool.ruff.lint] select = ["E", "F", "I"] ignore = [] [tool.ruff] line-length = 88 target-version = "py312" [tool.uv] dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] ``` ## /examples/servers/lambda-weather/terraform/.terraform.lock.hcl ```hcl path="/examples/servers/lambda-weather/terraform/.terraform.lock.hcl" # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { version = "4.67.0" constraints = "~> 4.16" hashes = [ "h1:5Zfo3GfRSWBaXs4TGQNOflr1XaYj6pRnVJLX5VAjFX4=", "zh:0843017ecc24385f2b45f2c5fce79dc25b258e50d516877b3affee3bef34f060", "zh:19876066cfa60de91834ec569a6448dab8c2518b8a71b5ca870b2444febddac6", "zh:24995686b2ad88c1ffaa242e36eee791fc6070e6144f418048c4ce24d0ba5183", "zh:4a002990b9f4d6d225d82cb2fb8805789ffef791999ee5d9cb1fef579aeff8f1", "zh:559a2b5ace06b878c6de3ecf19b94fbae3512562f7a51e930674b16c2f606e29", "zh:6a07da13b86b9753b95d4d8218f6dae874cf34699bca1470d6effbb4dee7f4b7", "zh:768b3bfd126c3b77dc975c7c0e5db3207e4f9997cf41aa3385c63206242ba043", "zh:7be5177e698d4b547083cc738b977742d70ed68487ce6f49ecd0c94dbf9d1362", "zh:8b562a818915fb0d85959257095251a05c76f3467caa3ba95c583ba5fe043f9b", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", "zh:9c385d03a958b54e2afd5279cd8c7cbdd2d6ca5c7d6a333e61092331f38af7cf", "zh:b3ca45f2821a89af417787df8289cb4314b273d29555ad3b2a5ab98bb4816b3b", "zh:da3c317f1db2469615ab40aa6baba63b5643bae7110ff855277a1fb9d8eb4f2c", "zh:dc6430622a8dc5cdab359a8704aec81d3825ea1d305bbb3bbd032b1c6adfae0c", "zh:fac0d2ddeadf9ec53da87922f666e1e73a603a611c57bcbc4b86ac2821619b1d", ] } ``` ## /examples/servers/lambda-weather/terraform/main.tf ```tf path="/examples/servers/lambda-weather/terraform/main.tf" data "aws_ecr_authorization_token" "token" {} resource "aws_ecr_repository" "weather" { name = var.resources_name image_tag_mutability = "MUTABLE" force_delete = true image_scanning_configuration { scan_on_push = false } # This is used when initially creating the repository, to push an empty dummy image # to it. This is because when we provision the Lambda, it fails to reference an # empty repository. provisioner "local-exec" { command = < dict[str, Any] | None: """Make a request to the NWS API with proper error handling.""" headers = {"User-Agent": USER_AGENT, "Accept": "application/geo+json"} async with httpx.AsyncClient() as client: try: response = await client.get(url, headers=headers, timeout=30.0) response.raise_for_status() return response.json() except Exception: return None def format_alert(feature: dict) -> str: """Format an alert feature into a readable string.""" props = feature["properties"] return f""" Event: {props.get('event', 'Unknown')} Area: {props.get('areaDesc', 'Unknown')} Severity: {props.get('severity', 'Unknown')} Description: {props.get('description', 'No description available')} Instructions: {props.get('instruction', 'No specific instructions provided')} """ @mcp.tool() async def get_alerts(state: str) -> str: """Get weather alerts for a US state. Args: state: Two-letter US state code (e.g. CA, NY) """ url = f"{NWS_API_BASE}/alerts/active/area/{state}" data = await make_nws_request(url) if not data or "features" not in data: return "Unable to fetch alerts or no alerts found." if not data["features"]: return "No active alerts for this state." alerts = [format_alert(feature) for feature in data["features"]] return "\n---\n".join(alerts) @mcp.tool() async def get_forecast(latitude: float, longitude: float) -> str: """Get weather forecast for a location. Args: latitude: Latitude of the location longitude: Longitude of the location """ # First get the forecast grid endpoint points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}" points_data = await make_nws_request(points_url) if not points_data: return "Unable to fetch forecast data for this location." # Get the forecast URL from the points response forecast_url = points_data["properties"]["forecast"] forecast_data = await make_nws_request(forecast_url) if not forecast_data: return "Unable to fetch detailed forecast." # Format the periods into a readable forecast periods = forecast_data["properties"]["periods"] forecasts = [] for period in periods[:5]: # Only show next 5 periods forecast = f""" {period['name']}: Temperature: {period['temperature']}°{period['temperatureUnit']} Wind: {period['windSpeed']} {period['windDirection']} Forecast: {period['detailedForecast']} """ forecasts.append(forecast) return "\n---\n".join(forecasts) def main(): mcp.run(transport="http") handler = mcp.get_lambda_handler() ``` ## /examples/servers/simple-prompt/.python-version ```python-version path="/examples/servers/simple-prompt/.python-version" 3.10 ``` ## /examples/servers/simple-prompt/README.md # MCP Simple Prompt A simple MCP server that exposes a customizable prompt template with optional context and topic parameters. ## Usage Start the server using either stdio (default) or SSE transport: ```bash # Using stdio transport (default) uv run mcp-simple-prompt # Using SSE transport on custom port uv run mcp-simple-prompt --transport sse --port 8000 ``` The server exposes a prompt named "simple" that accepts two optional arguments: - `context`: Additional context to consider - `topic`: Specific topic to focus on ## Example Using the MCP client, you can retrieve the prompt like this using the STDIO transport: ```python import asyncio from mcpengine.client.session import ClientSession from mcpengine.client.stdio import StdioServerParameters, stdio_client async def main(): async with stdio_client( StdioServerParameters(command="uv", args=["run", "mcp-simple-prompt"]) ) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # List available prompts prompts = await session.list_prompts() print(prompts) # Get the prompt with arguments prompt = await session.get_prompt( "simple", { "context": "User is a software developer", "topic": "Python async programming", }, ) print(prompt) asyncio.run(main()) ``` ## /examples/servers/simple-prompt/mcp_simple_prompt/__init__.py ```py path="/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py" ``` ## /examples/servers/simple-prompt/mcp_simple_prompt/__main__.py ```py path="/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py" import sys from .server import main sys.exit(main()) ``` ## /examples/servers/simple-prompt/mcp_simple_prompt/server.py ```py path="/examples/servers/simple-prompt/mcp_simple_prompt/server.py" import anyio import click import mcpengine.types as types from mcpengine.server.lowlevel import Server def create_messages( context: str | None = None, topic: str | None = None ) -> list[types.PromptMessage]: """Create the messages for the prompt.""" messages = [] # Add context if provided if context: messages.append( types.PromptMessage( role="user", content=types.TextContent( type="text", text=f"Here is some relevant context: {context}" ), ) ) # Add the main prompt prompt = "Please help me with " if topic: prompt += f"the following topic: {topic}" else: prompt += "whatever questions I may have." messages.append( types.PromptMessage( role="user", content=types.TextContent(type="text", text=prompt) ) ) return messages @click.command() @click.option("--port", default=8000, help="Port to listen on for SSE") @click.option( "--transport", type=click.Choice(["stdio", "sse"]), default="stdio", help="Transport type", ) def main(port: int, transport: str) -> int: app = Server("mcp-simple-prompt") @app.list_prompts() async def list_prompts() -> list[types.Prompt]: return [ types.Prompt( name="simple", description="A simple prompt that can take optional context and topic " "arguments", arguments=[ types.PromptArgument( name="context", description="Additional context to consider", required=False, ), types.PromptArgument( name="topic", description="Specific topic to focus on", required=False, ), ], ) ] @app.get_prompt() async def get_prompt( name: str, arguments: dict[str, str] | None = None ) -> types.GetPromptResult: if name != "simple": raise ValueError(f"Unknown prompt: {name}") if arguments is None: arguments = {} return types.GetPromptResult( messages=create_messages( context=arguments.get("context"), topic=arguments.get("topic") ), description="A simple prompt with optional context and topic arguments", ) if transport == "sse": from mcpengine.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.routing import Mount, Route sse = SseServerTransport("/messages/") async def handle_sse(request): async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: await app.run( streams[0], streams[1], app.create_initialization_options() ) starlette_app = Starlette( debug=True, routes=[ Route("/sse", endpoint=handle_sse), Mount("/messages/", app=sse.handle_post_message), ], ) import uvicorn uvicorn.run(starlette_app, host="0.0.0.0", port=port) else: from mcpengine.server.stdio import stdio_server async def arun(): async with stdio_server() as streams: await app.run( streams[0], streams[1], app.create_initialization_options() ) anyio.run(arun) return 0 ``` ## /examples/servers/simple-prompt/pyproject.toml ```toml path="/examples/servers/simple-prompt/pyproject.toml" [project] name = "mcp-simple-prompt" version = "0.1.0" description = "A simple MCP server exposing a customizable prompt" readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Anthropic, PBC." }] maintainers = [ { name = "David Soria Parra", email = "davidsp@anthropic.com" }, { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, ] keywords = ["mcp", "mcpengine", "llm", "automation", "web", "fetch"] license = { text = "MIT" } classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] dependencies = ["anyio>=4.5", "click>=8.1.0", "httpx>=0.27", "mcp"] [project.scripts] mcp-simple-prompt = "mcp_simple_prompt.server:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["mcp_simple_prompt"] [tool.pyright] include = ["mcp_simple_prompt"] venvPath = "." venv = ".venv" [tool.ruff.lint] select = ["E", "F", "I"] ignore = [] [tool.ruff] line-length = 88 target-version = "py310" [tool.uv] dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] ``` ## /examples/servers/simple-resource/.python-version ```python-version path="/examples/servers/simple-resource/.python-version" 3.10 ``` ## /examples/servers/simple-resource/README.md # MCP Simple Resource A simple MCP server that exposes sample text files as resources. ## Usage Start the server using either stdio (default) or SSE transport: ```bash # Using stdio transport (default) uv run mcp-simple-resource # Using SSE transport on custom port uv run mcp-simple-resource --transport sse --port 8000 ``` The server exposes some basic text file resources that can be read by clients. ## Example Using the MCP client, you can retrieve resources like this using the STDIO transport: ```python import asyncio from mcpengine.types import AnyUrl from mcpengine.client.session import ClientSession from mcpengine.client.stdio import StdioServerParameters, stdio_client async def main(): async with stdio_client( StdioServerParameters(command="uv", args=["run", "mcp-simple-resource"]) ) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # List available resources resources = await session.list_resources() print(resources) # Get a specific resource resource = await session.read_resource(AnyUrl("file:///greeting.txt")) print(resource) asyncio.run(main()) ``` ## /examples/servers/simple-resource/mcp_simple_resource/__init__.py ```py path="/examples/servers/simple-resource/mcp_simple_resource/__init__.py" ``` ## /examples/servers/simple-resource/mcp_simple_resource/__main__.py ```py path="/examples/servers/simple-resource/mcp_simple_resource/__main__.py" import sys from .server import main sys.exit(main()) ``` ## /examples/servers/simple-resource/mcp_simple_resource/server.py ```py path="/examples/servers/simple-resource/mcp_simple_resource/server.py" import anyio import click import mcpengine.types as types from mcpengine.server.lowlevel import Server from pydantic import FileUrl SAMPLE_RESOURCES = { "greeting": "Hello! This is a sample text resource.", "help": "This server provides a few sample text resources for testing.", "about": "This is the simple-resource MCP server implementation.", } @click.command() @click.option("--port", default=8000, help="Port to listen on for SSE") @click.option( "--transport", type=click.Choice(["stdio", "sse"]), default="stdio", help="Transport type", ) def main(port: int, transport: str) -> int: app = Server("mcp-simple-resource") @app.list_resources() async def list_resources() -> list[types.Resource]: return [ types.Resource( uri=FileUrl(f"file:///{name}.txt"), name=name, description=f"A sample text resource named {name}", mimeType="text/plain", ) for name in SAMPLE_RESOURCES.keys() ] @app.read_resource() async def read_resource(uri: FileUrl) -> str | bytes: name = uri.path.replace(".txt", "").lstrip("/") if name not in SAMPLE_RESOURCES: raise ValueError(f"Unknown resource: {uri}") return SAMPLE_RESOURCES[name] if transport == "sse": from mcpengine.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.routing import Mount, Route sse = SseServerTransport("/messages/") async def handle_sse(request): async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: await app.run( streams[0], streams[1], app.create_initialization_options() ) starlette_app = Starlette( debug=True, routes=[ Route("/sse", endpoint=handle_sse), Mount("/messages/", app=sse.handle_post_message), ], ) import uvicorn uvicorn.run(starlette_app, host="0.0.0.0", port=port) else: from mcpengine.server.stdio import stdio_server async def arun(): async with stdio_server() as streams: await app.run( streams[0], streams[1], app.create_initialization_options() ) anyio.run(arun) return 0 ``` ## /examples/servers/simple-resource/pyproject.toml ```toml path="/examples/servers/simple-resource/pyproject.toml" [project] name = "mcp-simple-resource" version = "0.1.0" description = "A simple MCP server exposing sample text resources" readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Anthropic, PBC." }] maintainers = [ { name = "David Soria Parra", email = "davidsp@anthropic.com" }, { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, ] keywords = ["mcp", "mcpengine", "llm", "automation", "web", "fetch"] license = { text = "MIT" } classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] dependencies = ["anyio>=4.5", "click>=8.1.0", "httpx>=0.27", "mcp"] [project.scripts] mcp-simple-resource = "mcp_simple_resource.server:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["mcp_simple_resource"] [tool.pyright] include = ["mcp_simple_resource"] venvPath = "." venv = ".venv" [tool.ruff.lint] select = ["E", "F", "I"] ignore = [] [tool.ruff] line-length = 88 target-version = "py310" [tool.uv] dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] ``` ## /examples/servers/simple-tool/.python-version ```python-version path="/examples/servers/simple-tool/.python-version" 3.10 ``` ## /examples/servers/simple-tool/README.md A simple MCP server that exposes a website fetching tool. ## Usage Start the server using either stdio (default) or SSE transport: ```bash # Using stdio transport (default) uv run mcp-simple-tool # Using SSE transport on custom port uv run mcp-simple-tool --transport sse --port 8000 ``` The server exposes a tool named "fetch" that accepts one required argument: - `url`: The URL of the website to fetch ## Example Using the MCP client, you can use the tool like this using the STDIO transport: ```python import asyncio from mcpengine.client.session import ClientSession from mcpengine.client.stdio import StdioServerParameters, stdio_client async def main(): async with stdio_client( StdioServerParameters(command="uv", args=["run", "mcp-simple-tool"]) ) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # List available tools tools = await session.list_tools() print(tools) # Call the fetch tool result = await session.call_tool("fetch", {"url": "https://example.com"}) print(result) asyncio.run(main()) ``` ## /examples/servers/simple-tool/mcp_simple_tool/__init__.py ```py path="/examples/servers/simple-tool/mcp_simple_tool/__init__.py" ``` ## /examples/servers/simple-tool/mcp_simple_tool/__main__.py ```py path="/examples/servers/simple-tool/mcp_simple_tool/__main__.py" import sys from .server import main sys.exit(main()) ``` ## /examples/servers/simple-tool/mcp_simple_tool/server.py ```py path="/examples/servers/simple-tool/mcp_simple_tool/server.py" import anyio import click import httpx import mcpengine.types as types from mcpengine.server.lowlevel import Server async def fetch_website( url: str, ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: headers = { "User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)" } async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: response = await client.get(url) response.raise_for_status() return [types.TextContent(type="text", text=response.text)] @click.command() @click.option("--port", default=8000, help="Port to listen on for SSE") @click.option( "--transport", type=click.Choice(["stdio", "sse"]), default="stdio", help="Transport type", ) def main(port: int, transport: str) -> int: app = Server("mcp-website-fetcher") @app.call_tool() async def fetch_tool( name: str, arguments: dict ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: if name != "fetch": raise ValueError(f"Unknown tool: {name}") if "url" not in arguments: raise ValueError("Missing required argument 'url'") return await fetch_website(arguments["url"]) @app.list_tools() async def list_tools() -> list[types.Tool]: return [ types.Tool( name="fetch", description="Fetches a website and returns its content", inputSchema={ "type": "object", "required": ["url"], "properties": { "url": { "type": "string", "description": "URL to fetch", } }, }, ) ] if transport == "sse": from mcpengine.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.routing import Mount, Route sse = SseServerTransport("/messages/") async def handle_sse(request): async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: await app.run( streams[0], streams[1], app.create_initialization_options() ) starlette_app = Starlette( debug=True, routes=[ Route("/sse", endpoint=handle_sse), Mount("/messages/", app=sse.handle_post_message), ], ) import uvicorn uvicorn.run(starlette_app, host="0.0.0.0", port=port) else: from mcpengine.server.stdio import stdio_server async def arun(): async with stdio_server() as streams: await app.run( streams[0], streams[1], app.create_initialization_options() ) anyio.run(arun) return 0 ``` ## /examples/servers/simple-tool/pyproject.toml ```toml path="/examples/servers/simple-tool/pyproject.toml" [project] name = "mcp-simple-tool" version = "0.1.0" description = "A simple MCP server exposing a website fetching tool" readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Anthropic, PBC." }] maintainers = [ { name = "David Soria Parra", email = "davidsp@anthropic.com" }, { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, ] keywords = ["mcp", "llm", "automation", "web", "fetch"] license = { text = "MIT" } classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] dependencies = ["anyio>=4.5", "click>=8.1.0", "httpx>=0.27", "mcp"] [project.scripts] mcp-simple-tool = "mcp_simple_tool.server:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["mcp_simple_tool"] [tool.pyright] include = ["mcp_simple_tool"] venvPath = "." venv = ".venv" [tool.ruff.lint] select = ["E", "F", "I"] ignore = [] [tool.ruff] line-length = 88 target-version = "py310" [tool.uv] dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] ``` ## /examples/servers/smack/.dockerignore ```dockerignore path="/examples/servers/smack/.dockerignore" terraform .venv ``` ## /examples/servers/smack/.gitignore ```gitignore path="/examples/servers/smack/.gitignore" For a Terraform project, you'll want to ignore several types of files to keep your repository clean and secure. Here's what I'd recommend adding to your .gitignore file: # Local .terraform directories **/.terraform/* # .tfstate files *.tfstate *.tfstate.* # Crash log files crash.log crash.*.log # Exclude all .tfvars files, which might contain sensitive data *.tfvars *.tfvars.json # Ignore override files as they're usually used for local development override.tf override.tf.json *_override.tf *_override.tf.json # Ignore CLI configuration files .terraformrc terraform.rc # Ignore lock files - optional, some teams prefer to version these # .terraform.lock.hcl # Ignore plan output files tfplan *.tfplan # Ignore sensitive variable files *.auto.tfvars ``` ## /examples/servers/smack/Dockerfile ``` path="/examples/servers/smack/Dockerfile" # Copyright (c) 2025 Featureform, Inc. # # Licensed under the MIT License. See LICENSE file in the project root for full license information. FROM public.ecr.aws/lambda/python:3.12 # Set working directory in the container WORKDIR /var/task # Install uv RUN pip install --no-cache-dir uv # Copy the application code COPY . . # Install dependencies using uv with --system flag RUN uv pip install --system --no-cache-dir . # Expose port for the server EXPOSE 8000 # Environment variables for PostgreSQL connection ENV DB_HOST=postgres \ DB_NAME=smack \ DB_USER=postgres \ DB_PASSWORD=postgres \ DB_PORT=5432 # Command to run the web server CMD ["mcp_smack.server.handler"] ``` ## /examples/servers/smack/README.md # Smack - Message Storage Service MCPEngine Smack Demo Smack is a simple messaging service built on MCPEngine, demonstrating how to securely store and retrieve messages using PostgreSQL in a production-grade MCP environment. ## Overview Smack shows how MCPEngine integrates OAuth 2.1 authentication and persistent storage to create a secure, scalable, and reliable messaging service. It highlights the importance of robust authentication in LLM workflows and how MCP can be extended beyond local toy demos. > **Note:** For an overview of MCPEngine's architecture and why it matters, see the [MCPEngine repository](https://github.com/featureform/mcp-engine). ## Features * **OAuth 2.1 Authentication**: Securely authenticate users before they can list or post messages * **Persistent PostgreSQL Storage**: Messages are stored in a real database for reliability * **Docker Containerization**: Easily spin up Smack and its dependencies with Docker Compose * **Compatible with MCP Inspector & Claude Desktop**: Test locally or in production-like setups ## Prerequisites * Docker and Docker Compose * MCP Inspector (optional, for testing and interaction) * npx (optional, for running the MCP Inspector via npx) ## Quick Start Clone this repository and navigate to examples/smack: ```bash git clone https://github.com/featureform/mcp-engine.git cd mcp-engine/examples/smack ``` Start the service using Docker Compose: ```bash docker-compose up --build ``` This will: * Build and start the Smack server on http://localhost:8000 * Launch a PostgreSQL instance on port 5432 * Create necessary volumes for data persistence Connect to the service in one of two ways: ### a) MCPEngine Proxy (Local Approach) If you already have Claude Desktop or another stdio-based LLM client, you can locally run: ```bash mcpengine proxy http://localhost:8000/sse ``` The command spawns a local process that listens for stdio MCP requests and forwards them to http://localhost:8000/sse. The proxy also handles OAuth interactions if your Smack server is configured for authentication. ### b) Using Docker Run in Claude Desktop Alternatively, you can run the MCPEngine-Proxy container in Docker and configure Claude Desktop to point to it. Create or edit your config file at: ```bash ~/Library/Application Support/Claude/claude_desktop_config.json ``` Add the following: ```json { "mcpServers": { "smack_mcp_server": { "command": "bash", "args": [ "docker attach mcpengine_proxy || docker run --rm -i --net=host --name mcpengine_proxy featureformcom/mcpengine-proxy -host=http://localhost:8000 -debug -client_id=optional -client_secret=optional", ] } } } ``` Claude Desktop sees a local stdio server, while Docker runs the MCPEngine-Proxy container. The proxy container listens on port 8181, connects to your Smack server at localhost:8000, and passes along OAuth credentials if required. Restart Claude Desktop, and you should see "smack_mcp_server" in the list of available servers. ## Available Tools ### list_messages() Retrieves all posted messages from the database. Response Example: ``` 1. Hello, world! 2. Another message ... ``` ### post_message(message: str) Posts a new message to the database. Parameters: * `message`: The textual content to post Response: * Success: "Message posted successfully: '...'" * Failure: Returns an error message describing the issue ## Why Smack? * **Security**: Demonstrates how OAuth 2.1 flows protect messaging endpoints * **Scalability**: Runs on Docker with PostgreSQL for data persistence * **Practical Example**: Illustrates how real-world services can adopt MCP (and MCPEngine) for secure AI-driven workflows ## Further Reading * [MCPEngine Repository](https://github.com/featureform/mcp-engine) * [Official MCP Specification](https://modelcontextprotocol.io) ## Questions or Feedback? Join our [Slack community](https://join.slack.com/t/featureform-community/shared_invite/zt-xhqp2m4i-JOCaN1vRN2NDXSVif10aQg?mc_cid=80bdc03b3b&mc_eid=UNIQID) or open an issue! We're excited to see what you build with Smack and MCPEngine. ## /examples/servers/smack/docker-compose.yml ```yml path="/examples/servers/smack/docker-compose.yml" services: smack-server: build: . ports: - "8000:8000" restart: unless-stopped depends_on: postgres: condition: service_healthy environment: - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/smack - DB_MAX_RETRIES=10 - DB_RETRY_DELAY=5 postgres: image: postgres:15 ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data environment: - POSTGRES_PASSWORD=postgres - POSTGRES_USER=postgres - POSTGRES_DB=smack restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 volumes: postgres_data: ``` ## /examples/servers/smack/mcp_smack/__init__.py ```py path="/examples/servers/smack/mcp_smack/__init__.py" # Copyright (c) 2025 Featureform, Inc. # # Licensed under the MIT License. See LICENSE file in the # project root for full license information. ``` ## /examples/servers/smack/mcp_smack/__main__.py ```py path="/examples/servers/smack/mcp_smack/__main__.py" # Copyright (c) 2025 Featureform, Inc. # # Licensed under the MIT License. See LICENSE file in the # project root for full license information. import sys from .server import main sys.exit(main()) ``` ## /examples/servers/smack/mcp_smack/db/__init__.py ```py path="/examples/servers/smack/mcp_smack/db/__init__.py" # Copyright (c) 2025 Featureform, Inc. # # Licensed under the MIT License. See LICENSE file in the # project root for full license information. """ Database module for Smack Messaging Service. This module provides the database interface for the Smack messaging service. """ from .postgres import MessageDB __all__ = ["MessageDB"] ``` ## /examples/servers/smack/mcp_smack/db/postgres.py ```py path="/examples/servers/smack/mcp_smack/db/postgres.py" # Copyright (c) 2025 Featureform, Inc. # # Licensed under the MIT License. See LICENSE file in the # project root for full license information. """ PostgreSQL Database Interface for Smack Messaging Service. This module provides a robust database interface for storing and retrieving messages with proper connection management, error handling, and logging. """ import atexit import logging import os import time from contextlib import suppress from psycopg2 import pool # Configure logging logger = logging.getLogger(__name__) class MessageDB: """PostgreSQL database interface for message storage with connection pooling.""" # Class-level connection pool _connection_pool = None _pool_min_conn = 1 _pool_max_conn = 10 def __init__(self): """Initialize the database connection pool if not already initialized.""" if not hasattr(self, "_initialized"): self._initialize_connection_pool() # Register cleanup function atexit.register(self.close_connection) self._initialized = True def _initialize_connection_pool(self) -> None: """Initialize the database connection pool with retry logic.""" # Get connection parameters from environment with secure defaults database_url = os.environ.get("DATABASE_URL") max_retries = int(os.environ.get("DB_MAX_RETRIES", "10")) retry_delay = int(os.environ.get("DB_RETRY_DELAY", "5")) # Set pool size from environment or use defaults self._pool_min_conn = int(os.environ.get("DB_MIN_CONNECTIONS", "1")) self._pool_max_conn = int(os.environ.get("DB_MAX_CONNECTIONS", "10")) retry_count = 0 last_error = None while retry_count < max_retries: try: if database_url: # Use the complete DATABASE_URL if available self._connection_pool = pool.ThreadedConnectionPool( self._pool_min_conn, self._pool_max_conn, database_url ) logger.info( "PostgreSQL connection pool established using DATABASE_URL" ) else: # Fallback to individual parameters with secure defaults self.db_host = os.environ.get("DB_HOST", "localhost") self.db_name = os.environ.get("DB_NAME", "smack") self.db_user = os.environ.get("DB_USER", "postgres") self.db_password = os.environ.get("DB_PASSWORD", "") self.db_port = os.environ.get("DB_PORT", "5432") self._connection_pool = pool.ThreadedConnectionPool( self._pool_min_conn, self._pool_max_conn, host=self.db_host, database=self.db_name, user=self.db_user, password=self.db_password, port=self.db_port, ) logger.info( f"PostgreSQL connection pool established to " f"{self.db_host}:{self.db_port}/{self.db_name}" ) # Initialize database schema self._init_db() return except Exception as e: last_error = e retry_count += 1 logger.warning( f"Connection attempt {retry_count}/{max_retries} failed: {e}" ) if retry_count < max_retries: logger.info(f"Retrying in {retry_delay} seconds...") time.sleep(retry_delay) # If we get here, all retries failed logger.error( f"Failed to establish database connection after {max_retries} attempts: " f"{last_error}" ) logger.error( "Please ensure the database is running and accessible. " "Check your environment variables: " "DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD" ) raise ConnectionError(f"Could not connect to database: {last_error}") def _init_db(self) -> None: """Initialize the database schema if it doesn't exist.""" connection = None try: connection = self._get_connection() cursor = connection.cursor() # Create messages table with proper indexing cursor.execute(""" CREATE TABLE IF NOT EXISTS messages ( id SERIAL PRIMARY KEY, sender TEXT NOT NULL, content TEXT NOT NULL, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) connection.commit() cursor.close() logger.info("Database schema initialized successfully") except Exception as e: logger.error(f"Error initializing database schema: {e}") if connection: connection.rollback() raise finally: self._return_connection(connection) def _get_connection(self): """Get a connection from the pool with validation.""" if not self._connection_pool: logger.warning("Connection pool not initialized, attempting to reconnect") self._initialize_connection_pool() try: connection = self._connection_pool.getconn() # Test connection with a simple query cursor = connection.cursor() cursor.execute("SELECT 1") cursor.close() return connection except Exception as e: logger.error(f"Failed to get valid connection from pool: {e}") # Try to reinitialize the pool self._initialize_connection_pool() return self._connection_pool.getconn() def _return_connection(self, connection): """Return a connection to the pool safely.""" if connection and self._connection_pool: try: self._connection_pool.putconn(connection) except Exception as e: logger.warning(f"Error returning connection to pool: {e}") # Try to close it directly if returning fails with suppress(Exception): connection.close() def close_connection(self): """Close the database connection pool.""" if self._connection_pool: try: self._connection_pool.closeall() logger.info("PostgreSQL connection pool closed") self._connection_pool = None except Exception as e: logger.error(f"Error closing connection pool: {e}") def add_message(self, sender: str, content: str) -> bool: """ Add a new message to the database. Args: sender: The name/identifier of the message sender content: The message content Returns: bool: True if message was added successfully, False otherwise """ connection = None try: # Input validation if not sender or not sender.strip(): logger.warning("Attempted to add message with empty sender") return False if not content or not content.strip(): logger.warning(f"Attempted to add empty message from {sender}") return False connection = self._get_connection() cursor = connection.cursor() cursor.execute( "INSERT INTO messages (sender, content) VALUES (%s, %s) RETURNING id", (sender, content), ) message_id = cursor.fetchone()[0] connection.commit() cursor.close() logger.info(f"Message added successfully with ID {message_id}") return True except Exception as e: logger.error(f"Error adding message to database: {e}") if connection: connection.rollback() return False finally: self._return_connection(connection) def get_all_messages(self, limit: int = 100) -> list[tuple[int, str, str, str]]: """ Retrieve messages from the database with pagination. Args: limit: Maximum number of messages to retrieve (default: 100) Returns: List of tuples containing (id, sender, content, timestamp) """ connection = None try: connection = self._get_connection() cursor = connection.cursor() cursor.execute( "SELECT id, sender, content, timestamp " "FROM messages ORDER BY timestamp DESC LIMIT %s", (limit,), ) messages = cursor.fetchall() cursor.close() logger.info(f"Retrieved {len(messages)} messages successfully") return messages except Exception as e: logger.error(f"Error retrieving messages from database: {e}") return [] finally: self._return_connection(connection) def get_message_by_id(self, message_id: int) -> tuple[int, str, str, str] | None: """ Retrieve a specific message by its ID. Args: message_id: The ID of the message to retrieve Returns: Tuple containing (id, sender, content, timestamp) or None if not found """ connection = None try: if not isinstance(message_id, int) or message_id <= 0: logger.warning(f"Invalid message ID: {message_id}") return None connection = self._get_connection() cursor = connection.cursor() cursor.execute( "SELECT id, sender, content, timestamp FROM messages WHERE id = %s", (message_id,), ) message = cursor.fetchone() cursor.close() if message: logger.info(f"Retrieved message with ID {message_id}") else: logger.info(f"No message found with ID {message_id}") return message except Exception as e: logger.error(f"Error retrieving message from database: {e}") return None finally: self._return_connection(connection) def delete_message(self, message_id: int) -> bool: """ Delete a message from the database. Args: message_id: The ID of the message to delete Returns: bool: True if message was deleted successfully, False otherwise """ connection = None try: if not isinstance(message_id, int) or message_id <= 0: logger.warning(f"Invalid message ID for deletion: {message_id}") return False connection = self._get_connection() cursor = connection.cursor() cursor.execute("DELETE FROM messages WHERE id = %s", (message_id,)) deleted = cursor.rowcount > 0 connection.commit() cursor.close() if deleted: logger.info(f"Message with ID {message_id} deleted successfully") else: logger.info(f"No message found with ID {message_id} for deletion") return deleted except Exception as e: logger.error(f"Error deleting message from database: {e}") if connection: connection.rollback() return False finally: self._return_connection(connection) ``` ## /examples/servers/smack/mcp_smack/server.py ```py path="/examples/servers/smack/mcp_smack/server.py" #!/usr/bin/env python # Copyright (c) 2025 Featureform, Inc. # # Licensed under the MIT License. See LICENSE file in the # project root for full license information. """ Smack Messaging Server A MCPEngine-based messaging service that provides message listing and posting capabilities. """ import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass from mcpengine import Context, MCPEngine from mcpengine.server.auth.providers.config import IdpConfig from .db import MessageDB # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger("smack-server") @dataclass class AppContext: """Application context containing shared resources.""" db: MessageDB @asynccontextmanager async def app_lifespan(server: MCPEngine) -> AsyncIterator[AppContext]: """ Manage application lifecycle with type-safe context. Args: server: The MCPEngine server instance Yields: AppContext: The application context with initialized resources """ logger.info("Initializing application resources") db = MessageDB() try: yield AppContext(db=db) except Exception as e: logger.error(f"Error during application lifecycle: {e}") raise finally: logger.info("Shutting down application resources") try: await db.close_connection() except Exception as e: logger.error(f"Error closing database connection: {e}") mcp = MCPEngine( "smack", lifespan=app_lifespan, idp_config=IdpConfig( issuer_url="http://localhost:8080/realms/master" ), ) @mcp.auth(scopes=["messages:list"]) @mcp.tool() async def list_messages(ctx: Context) -> str: """ List all messages from the database. Args: ctx: The request context Returns: str: Formatted list of messages or notification if no messages exist """ logger.info("Handling list_messages request") try: app_ctx: AppContext = ctx.request_context.lifespan_context db: MessageDB = app_ctx.db messages = db.get_all_messages() if not messages: logger.info("No messages found in database") return "No messages available." message_list = [] for i, message in enumerate(messages, 1): sender = message[1] content = message[2] message_list.append(f"{i}. {sender}: {content}") logger.info(f"Retrieved {len(messages)} messages successfully") return "\n".join(message_list) except Exception as e: logger.error(f"Error listing messages: {e}") return f"An error occurred while retrieving messages: {str(e)}" @mcp.auth(scopes=["messages:post"]) @mcp.tool() async def post_message(ctx: Context, message: str) -> str: """ Post a new message to the database. Args: ctx: The request context, which includes authenticated user information message: The content of the message Returns: str: Success or failure message """ sender = ctx.user_name logger.info(f"Handling post_message request from {sender}") # Input validation if not sender or not sender.strip(): logger.warning("Attempted to post message with empty sender") if not message or not message.strip(): logger.warning(f"Attempted to post empty message from {sender}") return "Message content cannot be empty" app_ctx: AppContext = ctx.request_context.lifespan_context db: MessageDB = app_ctx.db success = db.add_message(sender, message) if success: logger.info(f"Message from {sender} posted successfully") return f"Message posted successfully: '{message}'" else: logger.error(f"Database operation failed when posting message from {sender}") return "Failed to post message to database" def main(): try: logger.info("Starting Smack server") logger.info("Connecting to database...") # Test database connection before starting the server db = MessageDB() try: # Test basic connection - will throw exception if connection fails db._get_connection() logger.info("Database connection established successfully") except Exception as e: logger.critical(f"Failed to establish database connection: {e}") logger.critical( "Please ensure the database is running and accessible. " "Check your environment variables:" "DATABASE_URL or DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD" ) return 1 finally: # Close the test connection db.close_connection() # Start the server mcp.run(transport="sse") except KeyboardInterrupt: logger.info("Server shutdown requested via KeyboardInterrupt") except Exception as e: logger.critical(f"Unhandled exception in server: {e}") return 1 return 0 handler = mcp.get_lambda_handler() ``` ## /examples/servers/smack/pyproject.toml ```toml path="/examples/servers/smack/pyproject.toml" [project] name = "mcp-smack" version = "0.1.0" description = "SMACK application" requires-python = ">=3.10" authors = [{ name = "Featureform, Inc." }] keywords = ["mcpengine", "mcp", "llm", "automation", "web", "fetch"] license = { text = "MIT" } classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] dependencies = ["psycopg2-binary==2.9.9", "mcpengine[cli,lambda]>=0.3.0"] [project.scripts] mcp-smack = "mcp_smack.server:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["mcp_smack"] [tool.pyright] include = ["mcp_smack"] venvPath = "." venv = ".venv" [tool.ruff.lint] select = ["E", "F", "I"] ignore = [] [tool.ruff] line-length = 88 target-version = "py310" [tool.uv] dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] ``` ## /examples/servers/smack/terraform/.terraform.lock.hcl ```hcl path="/examples/servers/smack/terraform/.terraform.lock.hcl" # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { version = "4.67.0" constraints = "~> 4.16" hashes = [ "h1:5Zfo3GfRSWBaXs4TGQNOflr1XaYj6pRnVJLX5VAjFX4=", "zh:0843017ecc24385f2b45f2c5fce79dc25b258e50d516877b3affee3bef34f060", "zh:19876066cfa60de91834ec569a6448dab8c2518b8a71b5ca870b2444febddac6", "zh:24995686b2ad88c1ffaa242e36eee791fc6070e6144f418048c4ce24d0ba5183", "zh:4a002990b9f4d6d225d82cb2fb8805789ffef791999ee5d9cb1fef579aeff8f1", "zh:559a2b5ace06b878c6de3ecf19b94fbae3512562f7a51e930674b16c2f606e29", "zh:6a07da13b86b9753b95d4d8218f6dae874cf34699bca1470d6effbb4dee7f4b7", "zh:768b3bfd126c3b77dc975c7c0e5db3207e4f9997cf41aa3385c63206242ba043", "zh:7be5177e698d4b547083cc738b977742d70ed68487ce6f49ecd0c94dbf9d1362", "zh:8b562a818915fb0d85959257095251a05c76f3467caa3ba95c583ba5fe043f9b", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", "zh:9c385d03a958b54e2afd5279cd8c7cbdd2d6ca5c7d6a333e61092331f38af7cf", "zh:b3ca45f2821a89af417787df8289cb4314b273d29555ad3b2a5ab98bb4816b3b", "zh:da3c317f1db2469615ab40aa6baba63b5643bae7110ff855277a1fb9d8eb4f2c", "zh:dc6430622a8dc5cdab359a8704aec81d3825ea1d305bbb3bbd032b1c6adfae0c", "zh:fac0d2ddeadf9ec53da87922f666e1e73a603a611c57bcbc4b86ac2821619b1d", ] } provider "registry.terraform.io/hashicorp/random" { version = "3.7.2" hashes = [ "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=", "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", ] } ``` ## /examples/servers/smack/terraform/db.tf ```tf path="/examples/servers/smack/terraform/db.tf" data "aws_availability_zones" "available" { state = "available" } resource "random_password" "db" { length = 16 special = true override_special = "_!%^" } resource "aws_db_instance" "db" { db_name = "smack" allocated_storage = 10 engine = "postgres" engine_version = "17.2" instance_class = "db.t4g.micro" skip_final_snapshot = true publicly_accessible = true db_subnet_group_name = aws_db_subnet_group.db.name vpc_security_group_ids = [aws_security_group.db.id] username = "postgres" password = random_password.db.result } resource "aws_security_group" "db" { name = var.resources_name vpc_id = aws_vpc.db.id egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_vpc" "db" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true enable_dns_support = true } resource "aws_db_subnet_group" "db" { name = "subnet" subnet_ids = aws_subnet.db[*].id } variable "public_subnet_cidrs" { type = list(string) description = "Public Subnet CIDR values" default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] } resource "aws_internet_gateway" "db" { vpc_id = aws_vpc.db.id } resource "aws_route_table" "db" { vpc_id = aws_vpc.db.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.db.id } } resource "aws_subnet" "db" { vpc_id = aws_vpc.db.id count = length(var.public_subnet_cidrs) cidr_block = var.public_subnet_cidrs[count.index] availability_zone = data.aws_availability_zones.available.names[count.index] } resource "aws_route_table_association" "db" { count = length(var.public_subnet_cidrs) subnet_id = aws_subnet.db[count.index].id route_table_id = aws_route_table.db.id } ``` ## /examples/servers/smack/terraform/ecr.tf ```tf path="/examples/servers/smack/terraform/ecr.tf" data "aws_ecr_authorization_token" "token" {} resource "aws_ecr_repository" "weather" { name = var.resources_name image_tag_mutability = "MUTABLE" force_delete = true image_scanning_configuration { scan_on_push = false } # This is used when initially creating the repository, to push an empty dummy image # to it. This is because when we provision the Lambda, it fails to reference an # empty repository. provisioner "local-exec" { command = <= a.opts.MaxAuthAttempts { if a.lastAuthAttempt.IsZero() || now.Sub(a.lastAuthAttempt) >= a.opts.AuthCooldownPeriod { a.logger.Debug("Resetting authentication attempt counter after cooldown") a.authAttempts = 0 } else { return false, fmt.Errorf("maximum authentication attempts (%d) exceeded", a.opts.MaxAuthAttempts) } } a.authAttempts++ a.lastAuthAttempt = now a.logger.Debugf("Authentication attempt %d of %d", a.authAttempts, a.opts.MaxAuthAttempts) return true, nil } // ResetAuthAttempts resets the authentication attempt counter, // typically after a successful authentication. func (a *AuthManager) ResetAuthAttempts() { a.authAttemptsLock.Lock() defer a.authAttemptsLock.Unlock() a.lastAuthAttempt = time.Time{} a.authAttempts = 0 a.logger.Debug("Authentication attempt counter reset after successful token usage") } // HandleAuthChallenge handles a 401 response and starts the authentication flow. // It returns the authorization URL, a waiter function that blocks until authentication completes, // and an error. func (a *AuthManager) HandleAuthChallenge(ctx context.Context, resp *http.Response) (string, func(), error) { // Reset the auth channel, in case this isn't the first call. a.authCompleteChan = make(chan struct{}) canAttempt, err := a.CanAttemptAuth() if !canAttempt { return "", nil, fmt.Errorf("authentication not attempted: %w", err) } wwwAuth := resp.Header.Get("WWW-Authenticate") if wwwAuth == "" { // Amazon remaps certain headers for security reasons. This is one of those headers. wwwAuth = resp.Header.Get("X-Amzn-Remapped-Www-Authenticate") if wwwAuth == "" { return "", nil, fmt.Errorf("no WWW-Authenticate header in 401 response") } } a.logger.Debugf("Received WWW-Authenticate header: %s", wwwAuth) scopes, err := parseScopes(wwwAuth) if err != nil { a.logger.Debugf("Error parsing scopes: %v; using default scopes", err) scopes = []string{oidc.ScopeOpenID, "profile", "email"} } serverURL, err := extractServerURL(resp.Request.URL) if err != nil { a.logger.Warnf("Failed to extract server URL: %v", err) return "", nil, fmt.Errorf("failed to extract server URL: %w", err) } a.serverURL = serverURL if err := a.fetchOIDCConfiguration(ctx); err != nil { return "", nil, fmt.Errorf("failed to fetch OIDC configuration: %w", err) } if err := a.initOAuth2Config(ctx, scopes); err != nil { return "", nil, fmt.Errorf("failed to initialize OAuth2 configuration: %w", err) } verifier := oauth2.GenerateVerifier() a.verifier = verifier if err := a.startAuthServer(ctx); err != nil { return "", nil, fmt.Errorf("failed to start auth server: %w", err) } state := generateState() authURL := a.oauth2Config.AuthCodeURL( state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier), ) // Waiter blocks until the authentication flow is complete. waiter := func() { <-a.authCompleteChan } return authURL, waiter, nil } // GetAccessToken returns the current access token. func (a *AuthManager) GetAccessToken() string { a.tokenMutex.RLock() defer a.tokenMutex.RUnlock() return a.accessToken } // fetchOIDCConfiguration retrieves the OpenID Connect configuration from the server. func (a *AuthManager) fetchOIDCConfiguration(ctx context.Context) error { configURL := a.serverURL + a.opts.OIDCConfigPath a.logger.Debugf("Fetching OIDC configuration from %s", configURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL, nil) if err != nil { return fmt.Errorf("failed to create request for OIDC configuration: %w", err) } resp, err := a.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to fetch OIDC configuration: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch OIDC configuration, status: %s", resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read OIDC configuration response: %w", err) } if err := json.Unmarshal(body, &a.oidcConfig); err != nil { return fmt.Errorf("failed to parse OIDC configuration: %w", err) } a.logger.Debugf("OIDC configuration fetched: auth_endpoint=%s, token_endpoint=%s", a.oidcConfig.AuthorizationEndpoint, a.oidcConfig.TokenEndpoint) return nil } // initOAuth2Config initializes the OAuth2 configuration and OIDC provider. func (a *AuthManager) initOAuth2Config(ctx context.Context, scopes []string) error { a.oauth2Config = oauth2.Config{ ClientID: a.clientID, ClientSecret: a.clientSecret, RedirectURL: a.redirectURL, Endpoint: oauth2.Endpoint{ AuthURL: a.oidcConfig.AuthorizationEndpoint, TokenURL: a.oidcConfig.TokenEndpoint, }, Scopes: scopes, } return nil } // startAuthServer starts an HTTP server to handle the authentication callback. // It accepts a context that, when canceled, will cause the server to shut down gracefully. func (a *AuthManager) startAuthServer(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc(a.opts.CallbackPath, a.handleCallback) a.server = &http.Server{ Addr: fmt.Sprintf(":%d", a.opts.ListenPort), Handler: mux, } a.logger.Debugf("Starting authentication server on port %d", a.opts.ListenPort) // Listen for context cancellation to shut down the server. go func() { <-ctx.Done() a.logger.Debug("Context canceled; shutting down auth server") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := a.server.Shutdown(shutdownCtx); err != nil { a.logger.Errorf("Error shutting down auth server: %v", err) } }() go func() { if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { a.logger.Errorf("HTTP server error: %v", err) } }() return nil } // handleCallback processes the authentication callback request. func (a *AuthManager) handleCallback(w http.ResponseWriter, r *http.Request) { ctx := r.Context() code := r.URL.Query().Get("code") if code == "" { http.Error(w, "missing code in request", http.StatusBadRequest) return } oauth2Token, err := a.oauth2Config.Exchange( ctx, code, oauth2.VerifierOption(a.verifier), ) if err != nil { http.Error(w, "failed to exchange token: "+err.Error(), http.StatusInternalServerError) return } a.tokenMutex.Lock() a.accessToken = oauth2Token.AccessToken a.tokenMutex.Unlock() w.Header().Set("Content-Type", "text/html") w.Write([]byte(` Authentication Successful

Authentication Successful

You can now close this window and return to the application.

`)) go func() { time.Sleep(1 * time.Second) a.shutdown() close(a.authCompleteChan) }() } // shutdown gracefully stops the authentication server. func (a *AuthManager) shutdown() { if a.server != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() a.logger.Debug("Shutting down authentication server") if err := a.server.Shutdown(ctx); err != nil { a.logger.Errorf("Error shutting down server: %v", err) } } } // parseScopes extracts scopes from the WWW-Authenticate header. func parseScopes(header string) ([]string, error) { if !strings.HasPrefix(header, "Bearer ") { return nil, fmt.Errorf("invalid WWW-Authenticate header, expected Bearer token: %s", header) } parts := strings.Split(strings.TrimPrefix(header, "Bearer "), ",") for _, part := range parts { part = strings.TrimSpace(part) if strings.HasPrefix(part, "scope=") { scopesVal := part[len("scope="):] scopesVal = strings.Trim(scopesVal, "\"") rawScopes := strings.Fields(scopesVal) var scopes []string for _, rawScope := range rawScopes { scope := strings.Trim(rawScope, "'") scopes = append(scopes, scope) } return scopes, nil } } // Fallback to default scopes if none found. return []string{oidc.ScopeOpenID, "profile", "email"}, nil } // extractServerURL constructs the base URL from the provided URL. func extractServerURL(u *url.URL) (string, error) { if u == nil { return "", fmt.Errorf("nil URL provided") } return fmt.Sprintf("%s://%s", u.Scheme, u.Host), nil } // generateState creates a random state string for CSRF protection. func generateState() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { // Fallback: use a timestamp if random generation fails. return fmt.Sprintf("%d", time.Now().UnixNano()) } return base64.StdEncoding.EncodeToString(b) } ``` ## /mcpengine-proxy/auth_test.go ```go path="/mcpengine-proxy/auth_test.go" package mcpengine import ( "context" "fmt" "io" "net/http" "net/http/httptest" "net/url" "reflect" "strings" "testing" "time" "go.uber.org/zap" ) // TestResolveConfig tests the configuration resolution logic func TestResolveConfig(t *testing.T) { testCases := []struct { name string input *AuthConfig expected *AuthConfig }{ { name: "nil config", input: nil, expected: &AuthConfig{ ListenPort: 8181, CallbackPath: "/callback", OIDCConfigPath: "/.well-known/openid-configuration", MaxAuthAttempts: 3, AuthCooldownPeriod: 15 * time.Second, }, }, { name: "partial config", input: &AuthConfig{ ClientID: "test-client", }, expected: &AuthConfig{ ClientID: "test-client", ClientSecret: "", ListenPort: 8181, CallbackPath: "/callback", OIDCConfigPath: "/.well-known/openid-configuration", MaxAuthAttempts: 3, AuthCooldownPeriod: 15 * time.Second, }, }, { name: "complete custom config", input: &AuthConfig{ ClientID: "test-client", ClientSecret: "test-secret", ListenPort: 9000, CallbackPath: "/custom-callback", OIDCConfigPath: "/custom-config", MaxAuthAttempts: 5, AuthCooldownPeriod: 30 * time.Second, }, expected: &AuthConfig{ ClientID: "test-client", ClientSecret: "test-secret", ListenPort: 9000, CallbackPath: "/custom-callback", OIDCConfigPath: "/custom-config", MaxAuthAttempts: 5, AuthCooldownPeriod: 30 * time.Second, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := resolveConfig(tc.input) if !reflect.DeepEqual(result, tc.expected) { t.Errorf("Expected %+v, got %+v", tc.expected, result) } }) } } // TestNewAuthManager tests the AuthManager constructor func TestNewAuthManager(t *testing.T) { logger := zap.NewNop().Sugar() t.Run("with nil config", func(t *testing.T) { auth := NewAuthManager(nil, logger) if auth == nil { t.Fatal("NewAuthManager returned nil") } if auth.redirectURL != "http://localhost:8181/callback" { t.Errorf("Expected redirectURL to be http://localhost:8181/callback, got %s", auth.redirectURL) } if auth.clientID != "" || auth.clientSecret != "" { t.Errorf("Expected empty clientID/secret, got %s/%s", auth.clientID, auth.clientSecret) } }) t.Run("with custom config", func(t *testing.T) { config := &AuthConfig{ ClientID: "client-123", ClientSecret: "secret-456", ListenPort: 9999, } auth := NewAuthManager(config, logger) if auth.clientID != "client-123" { t.Errorf("Expected clientID to be client-123, got %s", auth.clientID) } if auth.clientSecret != "secret-456" { t.Errorf("Expected clientSecret to be secret-456, got %s", auth.clientSecret) } if auth.redirectURL != "http://localhost:9999/callback" { t.Errorf("Expected redirectURL to be http://localhost:9999/callback, got %s", auth.redirectURL) } }) } // TestAuthManager_CanAttemptAuth tests the auth retry limiting logic func TestAuthManager_CanAttemptAuth(t *testing.T) { logger := zap.NewNop().Sugar() t.Run("default config", func(t *testing.T) { auth := NewAuthManager(nil, logger) // First attempt should succeed can, err := auth.CanAttemptAuth() if !can || err != nil { t.Errorf("First attempt should succeed, got can=%v, err=%v", can, err) } // Try more attempts for i := 0; i < 2; i++ { auth.CanAttemptAuth() } // Fourth attempt should fail (default MaxAuthAttempts is 3) can, err = auth.CanAttemptAuth() if can || err == nil { t.Errorf("Expected failure after max attempts, got can=%v, err=%v", can, err) } }) t.Run("with reset", func(t *testing.T) { auth := NewAuthManager(nil, logger) // Exhaust attempts for i := 0; i < 3; i++ { auth.CanAttemptAuth() } // Reset attempts auth.ResetAuthAttempts() // Should be able to try again can, err := auth.CanAttemptAuth() if !can || err != nil { t.Errorf("After reset, attempt should succeed, got can=%v, err=%v", can, err) } }) t.Run("with cooldown", func(t *testing.T) { // Custom config with shorter cooldown for testing config := &AuthConfig{ MaxAuthAttempts: 1, // Only allow 1 attempt AuthCooldownPeriod: 50 * time.Millisecond, // Short cooldown for testing } auth := NewAuthManager(config, logger) // First attempt should succeed can, _ := auth.CanAttemptAuth() if !can { t.Error("First attempt should succeed with custom config") } // Second attempt should fail can, _ = auth.CanAttemptAuth() if can { t.Error("Second attempt should fail with custom config") } // After cooldown, attempts should be allowed again time.Sleep(100 * time.Millisecond) // Wait for cooldown can, _ = auth.CanAttemptAuth() if !can { t.Error("Attempt after cooldown should succeed") } }) } // TestAuthManager_GetAccessToken tests token retrieval func TestAuthManager_GetAccessToken(t *testing.T) { logger := zap.NewNop().Sugar() auth := NewAuthManager(nil, logger) // Initially should be empty if token := auth.GetAccessToken(); token != "" { t.Errorf("Expected empty token initially, got %q", token) } // Set token and verify expectedToken := "test-access-token" auth.tokenMutex.Lock() auth.accessToken = expectedToken auth.tokenMutex.Unlock() if token := auth.GetAccessToken(); token != expectedToken { t.Errorf("Expected token %q, got %q", expectedToken, token) } } // TestParseScopes tests scope extraction from WWW-Authenticate headers func TestParseScopes(t *testing.T) { testCases := []struct { name string header string expectedScopes []string expectError bool }{ { name: "valid header with scope", header: `Bearer realm="test", scope="openid profile email"`, expectedScopes: []string{"openid", "profile", "email"}, expectError: false, }, { name: "valid header without scope", header: `Bearer realm="test"`, expectedScopes: []string{"openid", "profile", "email"}, // Default scopes expectError: false, }, { name: "invalid header format", header: `Basic realm="test"`, expectedScopes: nil, expectError: true, }, { name: "empty header", header: "", expectedScopes: nil, expectError: true, }, { name: "header with quoted scope values", header: `Bearer realm="test", scope="'openid' 'profile'"`, expectedScopes: []string{"openid", "profile"}, expectError: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { scopes, err := parseScopes(tc.header) if tc.expectError { if err == nil { t.Errorf("Expected error, got nil") } } else { if err != nil { t.Errorf("Unexpected error: %v", err) } if !reflect.DeepEqual(scopes, tc.expectedScopes) { t.Errorf("Expected scopes %v, got %v", tc.expectedScopes, scopes) } } }) } } // TestExtractServerURL tests URL extraction func TestExtractServerURL(t *testing.T) { testCases := []struct { name string input *url.URL expectedOutput string expectError bool }{ { name: "valid URL", input: &url.URL{Scheme: "https", Host: "example.com"}, expectedOutput: "https://example.com", expectError: false, }, { name: "valid URL with port", input: &url.URL{Scheme: "http", Host: "localhost:8080"}, expectedOutput: "http://localhost:8080", expectError: false, }, { name: "nil URL", input: nil, expectedOutput: "", expectError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result, err := extractServerURL(tc.input) if tc.expectError { if err == nil { t.Errorf("Expected error, got nil") } } else { if err != nil { t.Errorf("Unexpected error: %v", err) } if result != tc.expectedOutput { t.Errorf("Expected %q, got %q", tc.expectedOutput, result) } } }) } } // TestGenerateState tests state generation for CSRF protection func TestGenerateState(t *testing.T) { // Test multiple calls return different values state1 := generateState() state2 := generateState() if state1 == "" { t.Error("Generated state should not be empty") } if state1 == state2 { t.Error("Multiple calls to generateState should return different values") } // Check that the generated state is a valid base64 string if _, err := url.QueryUnescape(state1); err != nil { t.Errorf("Generated state is not URL-safe: %v", err) } } // TestFetchOIDCConfiguration tests the OIDC configuration fetching func TestFetchOIDCConfiguration(t *testing.T) { // Create a test server that returns OIDC configuration server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/.well-known/openid-configuration" { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "authorization_endpoint": "https://auth.example.com/auth", "token_endpoint": "https://auth.example.com/token", "issuer": "https://auth.example.com" }`)) } else { w.WriteHeader(http.StatusNotFound) } })) defer server.Close() logger := zap.NewNop().Sugar() auth := NewAuthManager(nil, logger) // Set the server URL auth.serverURL = server.URL // Test successful configuration fetch ctx := context.Background() err := auth.fetchOIDCConfiguration(ctx) if err != nil { t.Fatalf("Unexpected error: %v", err) } if auth.oidcConfig.AuthorizationEndpoint != "https://auth.example.com/auth" { t.Errorf("Wrong authorization endpoint: %s", auth.oidcConfig.AuthorizationEndpoint) } if auth.oidcConfig.TokenEndpoint != "https://auth.example.com/token" { t.Errorf("Wrong token endpoint: %s", auth.oidcConfig.TokenEndpoint) } if auth.oidcConfig.Issuer != "https://auth.example.com" { t.Errorf("Wrong issuer: %s", auth.oidcConfig.Issuer) } // Test with invalid server URL auth.serverURL = "invalid-url" err = auth.fetchOIDCConfiguration(ctx) if err == nil { t.Error("Expected error with invalid URL, got nil") } // Test with server that returns an error auth.serverURL = "http://localhost:1" // Should fail to connect err = auth.fetchOIDCConfiguration(ctx) if err == nil { t.Error("Expected error with unreachable server, got nil") } } // TestInitOAuth2Config tests OAuth2 configuration initialization func TestInitOAuth2Config(t *testing.T) { logger := zap.NewNop().Sugar() auth := NewAuthManager(&AuthConfig{ ClientID: "test-client", }, logger) // Set up OIDC config auth.oidcConfig = OpenIDConfiguration{ AuthorizationEndpoint: "https://auth.example.com/auth", TokenEndpoint: "https://auth.example.com/token", Issuer: "https://auth.example.com", } // This test is limited since we can't easily mock the OIDC provider // We'll just test that the OAuth2 config is set up correctly ctx := context.Background() scopes := []string{"openid", "profile"} // This will fail because we can't create a real provider in tests, // but we can check that the oauth2Config is set up correctly _ = auth.initOAuth2Config(ctx, scopes) if auth.oauth2Config.ClientID != "test-client" { t.Errorf("Wrong client ID: %s", auth.oauth2Config.ClientID) } if auth.oauth2Config.ClientSecret != "" { t.Errorf("Wrong client secret: %s", auth.oauth2Config.ClientSecret) } if auth.oauth2Config.RedirectURL != "http://localhost:8181/callback" { t.Errorf("Wrong redirect URL: %s", auth.oauth2Config.RedirectURL) } if auth.oauth2Config.Endpoint.AuthURL != "https://auth.example.com/auth" { t.Errorf("Wrong auth URL: %s", auth.oauth2Config.Endpoint.AuthURL) } if auth.oauth2Config.Endpoint.TokenURL != "https://auth.example.com/token" { t.Errorf("Wrong token URL: %s", auth.oauth2Config.Endpoint.TokenURL) } if !reflect.DeepEqual(auth.oauth2Config.Scopes, scopes) { t.Errorf("Wrong scopes: %v", auth.oauth2Config.Scopes) } } // TestHandleAuthChallenge tests the auth challenge handling func TestHandleAuthChallenge(t *testing.T) { // Mock HTTP client for OIDC config fetch mockHTTPClient := &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { // Mock OIDC config response if strings.Contains(req.URL.Path, ".well-known/openid-configuration") { return &http.Response{ StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{ "authorization_endpoint": "https://auth.example.com/auth", "token_endpoint": "https://auth.example.com/token", "issuer": "https://auth.example.com" }`)), Header: make(http.Header), }, nil } return nil, fmt.Errorf("unexpected request to %s", req.URL) }), } logger := zap.NewNop().Sugar() auth := NewAuthManager(&AuthConfig{ ClientID: "test-client", // Use small values for testing MaxAuthAttempts: 1, AuthCooldownPeriod: 50 * time.Millisecond, }, logger) // Replace the HTTP client auth.httpClient = mockHTTPClient // Create a mock 401 response resp := &http.Response{ StatusCode: http.StatusUnauthorized, Header: make(http.Header), Request: &http.Request{ URL: &url.URL{ Scheme: "https", Host: "api.example.com", }, }, } resp.Header.Set("WWW-Authenticate", `Bearer realm="example", scope="openid profile"`) // Test auth challenge handling ctx := context.Background() authURL, waiter, err := auth.HandleAuthChallenge(ctx, resp) // We expect this to fail in tests since we can't create a real OIDC provider // but we can check some of the behavior if err == nil { // Due to test mocking limitations, we don't expect this to succeed // But if somehow it does, at least check the auth URL if !strings.Contains(authURL, "auth.example.com") { t.Errorf("Auth URL doesn't contain expected host: %s", authURL) } // If it succeeded, the waiter should be non-nil if waiter == nil { t.Error("Waiter function is nil") } } // Test rate limiting // Try another auth attempt immediately - should be denied _, _, err = auth.HandleAuthChallenge(ctx, resp) if err == nil { t.Error("Expected rate limiting error, got nil") } // Wait for cooldown and try again time.Sleep(100 * time.Millisecond) _, _, err = auth.HandleAuthChallenge(ctx, resp) // This should still fail but for OIDC-related reasons, not rate limiting if err != nil && strings.Contains(err.Error(), "maximum authentication attempts") { t.Errorf("Should not get rate limiting error after cooldown: %v", err) } } // Helper for mocking HTTP responses type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } ``` ## /mcpengine-proxy/cmd/main.go ```go path="/mcpengine-proxy/cmd/main.go" package main import ( "context" "flag" "fmt" "os" "go.uber.org/zap" "mcpengine" ) func main() { host := flag.String("host", "localhost:8000", "The hostname. By default we connect to /sse") clientId := flag.String("client_id", "", "The ClientID to be used in OAuth") clientSecret := flag.String("client_secret", "", "The Client Secret to be used in OAuth (can be empty if using PKCE)") mode := flag.String("mode", "sse", "The style of HTTP communication to use with the server (one of: sse, http)") ssePath := flag.String("sse_path", "/sse", "The path to append to hostname for an /sse connection") mcpPath := flag.String("mcp_path", "/mcp", "The path to append to hostname for non-SSE POST") debug := flag.Bool("debug", false, "Enable debug logging") flag.Parse() if *mode != "sse" && *mode != "http" { fmt.Printf("Invalid mode: %s. Must be one of \"sse\", \"http\"\n", *mode) os.Exit(1) } var rawLogger *zap.Logger if *debug { l, err := zap.NewDevelopment() if err != nil { fmt.Printf("Failed to setup logger: %s\n", err) os.Exit(1) } rawLogger = l } else { l, err := zap.NewProduction() if err != nil { fmt.Printf("Failed to setup logger: %s\n", err) os.Exit(1) } rawLogger = l } logger := rawLogger.Sugar() if *host == "" { logger.Fatal("-host flag must be set") } engine, err := mcpengine.New(mcpengine.Config{ Endpoint: *host, UseSSE: *mode == "sse", SSEPath: *ssePath, MCPPath: *mcpPath, AuthConfig: &mcpengine.AuthConfig{ ClientID: *clientId, ClientSecret: *clientSecret, }, Logger: logger, }) if err != nil { logger.Fatalw("Failed to create MCPEngine", "err", err) } logger.Info("Starting MCPEngine") engine.Start(context.Background()) } ``` ## /mcpengine-proxy/docker-compose.yml ```yml path="/mcpengine-proxy/docker-compose.yml" version: '3.8' services: mcpengine: build: context: . dockerfile: Dockerfile image: mcpengine:latest container_name: mcpengine restart: unless-stopped ports: - "8181:8181" # Auth callback port environment: - HOST=localhost:8000 - SSE_PATH=/sse - DEBUG=false - CLIENT_ID=${CLIENT_ID:-} # Set via .env file or environment variable - CLIENT_SECRET=${CLIENT_SECRET:-} # Set via .env file or environment variable # Command-line arguments passed directly to the application command: [ "-host", "${HOST:-localhost:8000}", "-sse_path", "${SSE_PATH:-/sse}", "-debug", "${DEBUG:-false}", "-client_id", "${CLIENT_ID:-}", "-client_secret", "${CLIENT_SECRET:-}" ] stdin_open: true # Keep STDIN open for input tty: true # Allocate a pseudo-TTY healthcheck: test: ["CMD", "/app/mcpengine", "-help"] interval: 30s timeout: 5s retries: 3 start_period: 5s logging: driver: "json-file" options: max-size: "10m" max-file: "3" ``` ## /mcpengine-proxy/go.mod ```mod path="/mcpengine-proxy/go.mod" module mcpengine go 1.23.5 require ( github.com/r3labs/sse/v2 v2.10.0 go.uber.org/zap v1.27.0 ) require ( github.com/coreos/go-oidc v2.3.0+incompatible // indirect github.com/pquerna/cachecontrol v0.2.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 // indirect golang.org/x/net v0.0.0-20191116160921-f9c825593386 // indirect golang.org/x/oauth2 v0.28.0 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect ) ``` ## /mcpengine-proxy/go.sum ```sum path="/mcpengine-proxy/go.sum" github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0= github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20191116160921-f9c825593386 h1:ktbWvQrW08Txdxno1PiDpSxPXG6ndGsfnJjRRtkM0LQ= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ``` The content has been capped at 50000 tokens, and files over NaN bytes have been omitted. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.