diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..f9d56aac5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ +## Description + + +**Contributors Checklist** + + +- [ ] My change is related to issue #(issue number) +- [ ] I have followed the repository's development workflow +- [ ] I have tested my changes manually and by adding relevant tests +- [ ] I have performed all required documentation updates + +**Review Checklist** + + +- [ ] I have self-reviewed my changes +- [ ] My Pull Request is ready for review + +--- \ No newline at end of file diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index 40e85c7a9..03bef97fb 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v6 - name: Auto-close duplicate issues run: uv run scripts/auto_close_duplicates.py diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index 3f584478d..7e3b5a27b 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Generate Marvin App token id: marvin-token @@ -45,7 +45,7 @@ jobs: 4. Next, feed the results from steps 2 and 3 into another agent using the Task tool, so that it can filter out false positives that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed. - 5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates) + 5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates). If there are no duplicates, DO NOT COMMENT. Just exit. Notes for your agents: - Use `gh` to interact with GitHub, rather than web fetch @@ -72,8 +72,9 @@ jobs: - name: Run Marvin dedupe command uses: anthropics/claude-code-base-action@beta with: + model: claude-3-5-haiku-latest prompt_file: /tmp/claude-prompts/dedupe-prompt.txt allowed_tools: "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh api:*),Bash(gh issue comment:*),Task" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} claude_env: | GH_TOKEN: ${{ steps.marvin-token.outputs.token }} diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml index ae26a7f6e..bc5dfdcdc 100644 --- a/.github/workflows/marvin-label-triage.yml +++ b/.github/workflows/marvin-label-triage.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout base repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ github.repository }} ref: ${{ github.event.repository.default_branch }} @@ -151,7 +151,7 @@ jobs: prompt_file: /tmp/claude-prompts/triage-prompt.txt allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__get_pull_request_files" timeout_minutes: "5" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} mcp_config: /tmp/mcp-config/mcp-servers.json claude_env: | GH_TOKEN: ${{ steps.marvin-token.outputs.token }} diff --git a/.github/workflows/marvin.yml b/.github/workflows/marvin.yml index 52386e5da..8d26185a9 100644 --- a/.github/workflows/marvin.yml +++ b/.github/workflows/marvin.yml @@ -31,24 +31,18 @@ jobs: (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'marvin') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - # Set up Python environment - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" + - uses: actions/checkout@v5 # Install UV package manager - name: Install UV - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true cache-dependency-glob: "uv.lock" # Install project dependencies - name: Install dependencies - run: uv sync --dev + run: uv sync --python 3.12 # Install pre-commit hooks automatically - name: Install pre-commit hooks diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 49a55b17d..9bda21fe9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: id-token: write # For PyPI's trusted publishing steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml index 2ab2af8f4..de046d3ba 100644 --- a/.github/workflows/run-static.yml +++ b/.github/workflows/run-static.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v6 with: diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 07f2d93df..f78ff55d2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -34,7 +34,7 @@ jobs: timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v6 @@ -48,10 +48,10 @@ jobs: run: uv sync --frozen - name: Run tests (excluding integration and client_process) - run: uv run pytest tests -m "not integration and not client_process" + run: uv run pytest -v tests -m "not integration and not client_process" - name: Run client process tests separately - run: uv run pytest tests -m "client_process" -x + run: uv run pytest -v tests -m "client_process" -x run_integration_tests: name: "Run integration tests" @@ -59,7 +59,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v6 @@ -73,7 +73,7 @@ jobs: run: uv sync --frozen - name: Run integration tests - run: uv run pytest tests -m "integration" + run: uv run pytest -v tests -m "integration" env: FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }} FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }} diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml new file mode 100644 index 000000000..9b3fa71a6 --- /dev/null +++ b/.github/workflows/update-config-schema.yml @@ -0,0 +1,89 @@ +name: Update FastMCPConfig Schema + +# This workflow runs on merges to main to automatically update the config schema +# by creating a PR when changes are needed. + +on: + push: + branches: ["main"] + paths: + - "src/fastmcp/utilities/fastmcp_config/**" + - "!src/fastmcp/utilities/fastmcp_config/v1/schema.json" # Exclude the local schema file + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-config-schema: + timeout-minutes: 5 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Generate Marvin App token + id: marvin-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.MARVIN_APP_ID }} + private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --python 3.12 + + - name: Generate config schema + run: | + echo "🔄 Generating fastmcp.json schema..." + + # Generate schema in docs/public for web access + uv run python -c " + from fastmcp.utilities.fastmcp_config import generate_schema + generate_schema('docs/public/schemas/fastmcp.json/latest.json') + print('✅ Latest schema generated in docs/public') + " + + # Also update the v1 schema in docs/public + uv run python -c " + from fastmcp.utilities.fastmcp_config import generate_schema + generate_schema('docs/public/schemas/fastmcp.json/v1.json') + print('✅ v1 schema generated in docs/public') + " + + # Generate schema in the source directory for local development + uv run python -c " + from fastmcp.utilities.fastmcp_config import generate_schema + generate_schema('src/fastmcp/utilities/fastmcp_config/v1/schema.json') + print('✅ Schema generated in utilities/fastmcp_config/v1/') + " + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.marvin-token.outputs.token }} + commit-message: "chore: Update fastmcp.json schema" + title: "chore: Update fastmcp.json schema" + body: | + This PR updates the fastmcp.json schema files to match the current source code. + + The schema is automatically generated from `src/fastmcp/utilities/fastmcp_config/` to ensure consistency. + + **Note:** This PR is fully automated and will update itself with any subsequent changes to the schema, or close automatically if the schema becomes up-to-date through other means. Feel free to leave it open until you're ready to merge. + + 🤖 Generated by Marvin + branch: marvin/update-config-schema + delete-branch: true + author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" + committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" + + - name: Summary + run: | + echo "✅ Config schema generation workflow completed" + echo "PR will be created if there are changes, or closed if schema is already up to date" diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index 7e74da782..f275d5234 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -1,8 +1,7 @@ name: Update SDK Documentation # This workflow runs on merges to main to automatically update SDK docs -# without blocking PRs. SDK doc generation can fail if another PR merges -# first, so handling it post-merge prevents annoying CI failures for contributors. +# by creating a PR when changes are needed. on: push: @@ -22,9 +21,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + + - name: Generate Marvin App token + id: marvin-token + uses: actions/create-github-app-token@v2 with: - token: ${{ secrets.GITHUB_TOKEN }} + app-id: ${{ secrets.MARVIN_APP_ID }} + private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Install uv uses: astral-sh/setup-uv@v6 @@ -32,13 +36,8 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: uv sync --dev + run: uv sync --python 3.12 - name: Install just uses: extractions/setup-just@v3 @@ -48,34 +47,26 @@ jobs: echo "🔄 Generating SDK documentation..." just api-ref-all - - name: Check for changes - id: check_changes - run: | - if git diff --quiet docs/python-sdk/ docs/docs.json; then - echo "No changes detected in SDK documentation" - echo "has_changes=false" >> $GITHUB_OUTPUT - else - echo "Changes detected in SDK documentation" - echo "has_changes=true" >> $GITHUB_OUTPUT - fi + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.marvin-token.outputs.token }} + commit-message: "chore: Update SDK documentation" + title: "chore: Update SDK documentation" + body: | + This PR updates the auto-generated SDK documentation to reflect the latest source code changes. - - name: Commit and push changes - if: steps.check_changes.outputs.has_changes == 'true' - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add docs/python-sdk/ docs/docs.json - git commit -m "chore: Update SDK documentation + 📚 Documentation is automatically generated from the source code docstrings and type annotations. - 🤖 Generated with [Claude Code](https://claude.ai/code) + **Note:** This PR is fully automated and will update itself with any subsequent changes to the SDK, or close automatically if the documentation becomes up-to-date through other means. Feel free to leave it open until you're ready to merge. - Co-Authored-By: Claude " - git push + 🤖 Generated by Marvin + branch: marvin/update-sdk-docs + delete-branch: true + author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" + committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - name: Summary run: | - if [ "${{ steps.check_changes.outputs.has_changes }}" == "true" ]; then - echo "✅ SDK documentation updated and committed" - else - echo "✅ SDK documentation is already up to date" - fi + echo "✅ SDK documentation generation workflow completed" + echo "PR will be created if there are changes, or closed if documentation is already up to date" diff --git a/.gitignore b/.gitignore index f119f200d..887877553 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,13 @@ dmypy.json # Claude worktree management .claude-wt/worktrees +# Agents +/PLAN.md +/TODO.md +/STATUS.md + # Common FastMCP test files /test.py /server.py /client.py +/test.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 822641d16..74e3541da 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: - repo: local hooks: - id: ty - name: type check + name: ty check entry: uv run ty check language: system types: [python] @@ -37,4 +37,5 @@ repos: rev: v6.0.0 hooks: - id: no-commit-to-branch + name: prevent commits to main args: [--branch, main] diff --git a/docs/assets/schemas/fastmcp_config/latest.json b/docs/assets/schemas/fastmcp_config/latest.json new file mode 100644 index 000000000..81d0ad754 --- /dev/null +++ b/docs/assets/schemas/fastmcp_config/latest.json @@ -0,0 +1,348 @@ +{ + "$defs": { + "Deployment": { + "description": "Configuration for server deployment and runtime settings.", + "properties": { + "transport": { + "anyOf": [ + { + "enum": [ + "stdio", + "http", + "sse" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Transport protocol to use", + "title": "Transport" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Host to bind to when using HTTP transport", + "examples": [ + "127.0.0.1", + "0.0.0.0", + "localhost" + ], + "title": "Host" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Port to bind to when using HTTP transport", + "examples": [ + 8000, + 3000, + 5000 + ], + "title": "Port" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "URL path for the server endpoint", + "examples": [ + "/mcp/", + "/api/mcp/", + "/sse/" + ], + "title": "Path" + }, + "log_level": { + "anyOf": [ + { + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Log level for the server", + "title": "Log Level" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Working directory for the server process", + "examples": [ + ".", + "./src", + "/app" + ], + "title": "Cwd" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Environment variables to set when running the server", + "examples": [ + { + "API_KEY": "secret", + "DEBUG": "true" + } + ], + "title": "Env" + }, + "args": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arguments to pass to the server (after --)", + "examples": [ + [ + "--config", + "config.json", + "--debug" + ] + ], + "title": "Args" + } + }, + "title": "Deployment", + "type": "object" + }, + "Environment": { + "description": "Configuration for Python environment setup.", + "properties": { + "python": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Python version constraint", + "examples": [ + "3.10", + "3.11", + "3.12" + ], + "title": "Python" + }, + "dependencies": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Python packages to install with PEP 508 specifiers", + "examples": [ + [ + "fastmcp>=2.0,<3", + "httpx", + "pandas>=2.0" + ] + ], + "title": "Dependencies" + }, + "requirements": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to requirements.txt file", + "examples": [ + "requirements.txt", + "../requirements/prod.txt" + ], + "title": "Requirements" + }, + "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to project directory containing pyproject.toml", + "examples": [ + ".", + "../my-project" + ], + "title": "Project" + }, + "editable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Directory to install in editable mode", + "examples": [ + ".", + "../my-package" + ], + "title": "Editable" + } + }, + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", + "type": "object" + } + }, + "description": "Configuration file for FastMCP servers", + "properties": { + "$schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "description": "JSON schema for IDE support and validation", + "title": "$Schema" + }, + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", + "examples": [ + { + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" + } + ] + }, + "environment": { + "$ref": "#/$defs/Environment", + "description": "Python environment setup configuration" + }, + "deployment": { + "$ref": "#/$defs/Deployment", + "description": "Server deployment and runtime settings" + } + }, + "required": [ + "source" + ], + "title": "FastMCP Configuration", + "type": "object", + "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" +} diff --git a/docs/assets/schemas/fastmcp_config/v1.json b/docs/assets/schemas/fastmcp_config/v1.json new file mode 100644 index 000000000..81d0ad754 --- /dev/null +++ b/docs/assets/schemas/fastmcp_config/v1.json @@ -0,0 +1,348 @@ +{ + "$defs": { + "Deployment": { + "description": "Configuration for server deployment and runtime settings.", + "properties": { + "transport": { + "anyOf": [ + { + "enum": [ + "stdio", + "http", + "sse" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Transport protocol to use", + "title": "Transport" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Host to bind to when using HTTP transport", + "examples": [ + "127.0.0.1", + "0.0.0.0", + "localhost" + ], + "title": "Host" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Port to bind to when using HTTP transport", + "examples": [ + 8000, + 3000, + 5000 + ], + "title": "Port" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "URL path for the server endpoint", + "examples": [ + "/mcp/", + "/api/mcp/", + "/sse/" + ], + "title": "Path" + }, + "log_level": { + "anyOf": [ + { + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Log level for the server", + "title": "Log Level" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Working directory for the server process", + "examples": [ + ".", + "./src", + "/app" + ], + "title": "Cwd" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Environment variables to set when running the server", + "examples": [ + { + "API_KEY": "secret", + "DEBUG": "true" + } + ], + "title": "Env" + }, + "args": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arguments to pass to the server (after --)", + "examples": [ + [ + "--config", + "config.json", + "--debug" + ] + ], + "title": "Args" + } + }, + "title": "Deployment", + "type": "object" + }, + "Environment": { + "description": "Configuration for Python environment setup.", + "properties": { + "python": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Python version constraint", + "examples": [ + "3.10", + "3.11", + "3.12" + ], + "title": "Python" + }, + "dependencies": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Python packages to install with PEP 508 specifiers", + "examples": [ + [ + "fastmcp>=2.0,<3", + "httpx", + "pandas>=2.0" + ] + ], + "title": "Dependencies" + }, + "requirements": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to requirements.txt file", + "examples": [ + "requirements.txt", + "../requirements/prod.txt" + ], + "title": "Requirements" + }, + "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to project directory containing pyproject.toml", + "examples": [ + ".", + "../my-project" + ], + "title": "Project" + }, + "editable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Directory to install in editable mode", + "examples": [ + ".", + "../my-package" + ], + "title": "Editable" + } + }, + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", + "type": "object" + } + }, + "description": "Configuration file for FastMCP servers", + "properties": { + "$schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "description": "JSON schema for IDE support and validation", + "title": "$Schema" + }, + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", + "examples": [ + { + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" + } + ] + }, + "environment": { + "$ref": "#/$defs/Environment", + "description": "Python environment setup configuration" + }, + "deployment": { + "$ref": "#/$defs/Deployment", + "description": "Server deployment and runtime settings" + } + }, + "required": [ + "source" + ], + "title": "FastMCP Configuration", + "type": "object", + "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" +} diff --git a/docs/deployment/server-configuration.mdx b/docs/deployment/server-configuration.mdx index bee3eb72f..eb30fd2ba 100644 --- a/docs/deployment/server-configuration.mdx +++ b/docs/deployment/server-configuration.mdx @@ -1,16 +1,18 @@ --- -title: Server Configuration with fastmcp.json -sidebarTitle: Server Configuration -description: Use fastmcp.json for declarative server configuration +title: "Project Configuration" +sidebarTitle: "Project Configuration" +description: Use fastmcp.json for portable, declarative project configuration icon: file-code --- import { VersionBadge } from "/snippets/version-badge.mdx" - + FastMCP supports declarative configuration through `fastmcp.json` files. This is the canonical and preferred way to configure FastMCP projects, providing a single source of truth for server settings, dependencies, and deployment options that replaces complex command-line arguments. +The `fastmcp.json` file is designed to be a portable description of your server configuration that can be shared across environments and teams. When running from a `fastmcp.json` file, you can override any configuration values using CLI arguments. + ## Overview The `fastmcp.json` configuration file allows you to define all aspects of your FastMCP server in a structured, shareable format. Instead of remembering command-line arguments or writing shell scripts, you declare your server's configuration once and use it everywhere. @@ -27,89 +29,110 @@ fastmcp run This configuration approach ensures reproducible deployments across different environments, from local development to production servers. It works seamlessly with Claude Desktop, VS Code extensions, and any MCP-compatible client. -## JSON Schema Support +## File Structure + +The `fastmcp.json` configuration answers three fundamental questions about your server: + +- **Source** = WHERE does your server code live? +- **Environment** = WHAT environment setup does it require? +- **Deployment** = HOW should the server run? + +This conceptual model helps you understand the purpose of each configuration section and organize your settings effectively. The configuration file maps directly to these three concerns: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + // WHERE: Location of your server code + "path": "server.py", + "entrypoint": "mcp" + }, + "environment": { + // WHAT: Python environment and dependencies + }, + "deployment": { + // HOW: Runtime configuration + } +} +``` + +Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. + +### JSON Schema Support FastMCP provides JSON schemas for IDE autocomplete and validation. Add the schema reference to your `fastmcp.json` for enhanced developer experience: ```json { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" } } ``` Two schema URLs are available: -- **Version-specific**: `https://gofastmcp.com/schemas/fastmcp_config/v1.json` -- **Latest version**: `https://gofastmcp.com/schemas/fastmcp_config/latest.json` +- **Version-specific**: `https://gofastmcp.com/public/schemas/fastmcp.json/v1.json` +- **Latest version**: `https://gofastmcp.com/public/schemas/fastmcp.json/latest.json` Modern IDEs like VS Code will automatically provide autocomplete suggestions, validation, and inline documentation when the schema is specified. -## File Structure +### Source Configuration -The `fastmcp.json` file has three main sections, each controlling a different aspect of your server: +The source configuration determines **WHERE** your server code lives. It tells FastMCP how to find and load your server, whether it's a local Python file, a remote repository, or hosted in the cloud. This section is required and forms the foundation of your configuration. -```json -{ - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" - }, - "environment": { - // Python environment and dependencies - }, - "deployment": { - // Runtime configuration - } -} -``` - -Only the `entrypoint` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. - -## Configuration Fields - -### Entrypoint - -The entrypoint specifies which Python file and object contains your FastMCP server. This field is required and supports multiple formats to accommodate different project structures. - - - - The server entry point. Can be specified in three formats: + + + The server source configuration that determines where your server code lives. - **Object format** (recommended): Explicit file and object specification - ```json - "entrypoint": { - "file": "src/server.py", - "object": "mcp" - } - ``` + + The source type identifier that determines which implementation to use. Currently supports `"filesystem"` for local files. Future releases will add support for `"git"` and `"cloud"` source types. + - **String with object**: File path with colon and object name - ```json - "entrypoint": "src/server.py:app" - ``` - - **String format**: Simple path to Python file (searches for common names: mcp, server, app) - ```json - "entrypoint": "server.py" - ``` - - - - File paths are resolved relative to the configuration file's location - - If your `fastmcp.json` is in a project root and references `src/server.py`, FastMCP will look for the server at `/src/server.py` - - When no object is specified, FastMCP automatically searches for common server names: `mcp`, `server`, or `app` + + When `type` is `"filesystem"` (or omitted), the source points to a local Python file containing your FastMCP server: + + + Path to the Python file containing your FastMCP server. + + + + Name of the server instance or factory function within the module: + - Can be a FastMCP server instance (e.g., `mcp = FastMCP("MyServer")`) + - Can be a function with no arguments that returns a FastMCP server + - If not specified, FastMCP searches for common names: `mcp`, `server`, or `app` + + + **Example:** + ```json + "source": { + "type": "filesystem", + "path": "src/server.py", + "entrypoint": "mcp" + } + ``` + + Note: File paths are resolved relative to the configuration file's location. -### Environment + +**Future Source Types** -The environment section configures Python dependencies and version requirements. When specified, FastMCP uses `uv` to create an isolated environment for your server, ensuring reproducible deployments across different systems. +Future releases will support additional source types: +- **Git repositories** (`type: "git"`) for loading server code directly from version control +- **FastMCP Cloud** (`type: "cloud"`) for hosted servers with automatic scaling and management + - +### Environment Configuration + +The environment configuration determines **WHAT** environment setup your server requires. It controls the build-time setup of your Python environment using `uv`'s powerful dependency management. This section ensures your server runs with the exact Python version and dependencies it requires, creating isolated, reproducible environments across different systems. + +These settings leverage standard `uv` arguments for environment creation. When any environment field is specified, FastMCP automatically creates an isolated environment before running your server. This build-time configuration happens once when the server starts, not during runtime execution. + + Optional Python environment configuration. When any field is specified, FastMCP automatically creates an isolated environment using `uv`. @@ -143,9 +166,9 @@ The environment section configures Python dependencies and version requirements. - Path to a package to install in editable/development mode. + Path to a package to install in editable/development mode. Useful for local development when you want changes to be reflected immediately. ```json - "editable": "./my-package" + "editable": "." ``` @@ -157,11 +180,15 @@ When environment configuration is provided, FastMCP: 2. Installs the specified dependencies 3. Runs your server in this clean environment -### Deployment +This build-time setup ensures your server always has the dependencies it needs, without polluting your system Python or conflicting with other projects. -The deployment section controls runtime configuration including transport protocol, networking, logging, and environment variables. +### Deployment Configuration - +The deployment configuration controls **HOW** your server runs. It defines the runtime behavior including network settings, environment variables, and execution context. These settings determine how your server operates when it executes, from transport protocols to logging levels. + +Environment variables are included in this section because they're runtime configuration that affects how your server behaves when it executes, not how its environment is built. The deployment configuration is applied every time your server starts, controlling its operational characteristics. + + Optional runtime configuration for the server. @@ -220,243 +247,7 @@ The deployment section controls runtime configuration including transport protoc -## Usage with CLI Commands - -FastMCP automatically detects and uses `fastmcp.json` files, making server execution simple and consistent: - -```bash -# Auto-detect fastmcp.json in current directory -cd my-project -fastmcp run # No arguments needed! - -# Or specify a configuration file explicitly -fastmcp run prod.fastmcp.json -``` - -The configuration file works with all FastMCP commands: -- **`run`** - Start the server in production mode -- **`dev`** - Launch with the Inspector UI for development -- **`inspect`** - View server capabilities and configuration -- **`install`** - Install to Claude Desktop, Cursor, or other MCP clients - -When no file argument is provided, FastMCP searches the current directory for `fastmcp.json`. This means you can simply navigate to your project directory and run `fastmcp run` to start your server with all its configured settings. - -### Custom Naming Patterns - -You can use different configuration files for different environments: - -- `fastmcp.json` - Default configuration -- `dev.fastmcp.json` - Development settings -- `prod.fastmcp.json` - Production settings -- `test_fastmcp.json` - Test configuration - -Any file with "fastmcp.json" in the name is recognized as a configuration file. - -## Examples - - - - -A minimal configuration for a simple server: - -```json -{ - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" - } -} -``` -This configuration explicitly specifies the server object name (`app`), making it clear which object contains your FastMCP server. Uses all defaults: STDIO transport, no special dependencies, standard logging. - - - -A configuration optimized for local development: - -```json -{ - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": "src/server.py:app", - "environment": { - "python": "3.12", - "dependencies": ["fastmcp[dev]"], - "editable": "." - }, - "deployment": { - "transport": "http", - "host": "127.0.0.1", - "port": 8000, - "log_level": "DEBUG", - "env": { - "DEBUG": "true", - "ENV": "development" - } - } -} -``` - - - -A production-ready configuration with full dependency management: - -```json -{ - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "app/main.py", - "object": "mcp_server" - }, - "environment": { - "python": "3.11", - "requirements": "requirements/production.txt", - "project": "." - }, - "deployment": { - "transport": "http", - "host": "0.0.0.0", - "port": 3000, - "path": "/api/mcp/", - "log_level": "INFO", - "env": { - "ENV": "production", - "API_BASE_URL": "https://api.example.com", - "DATABASE_URL": "postgresql://user:pass@db.example.com/prod" - }, - "cwd": "/app", - "args": ["--workers", "4"] - } -} -``` - - - -Configuration for a data analysis server with scientific packages: - -```json -{ - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "analysis_server.py", - "object": "mcp" - }, - "environment": { - "python": "3.11", - "dependencies": [ - "pandas>=2.0", - "numpy", - "scikit-learn", - "matplotlib", - "jupyterlab" - ] - }, - "deployment": { - "transport": "stdio", - "env": { - "MATPLOTLIB_BACKEND": "Agg", - "DATA_PATH": "./datasets" - } - } -} -``` - - - -You can maintain multiple configuration files for different environments: - -**dev.fastmcp.json**: -```json -{ - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" - }, - "deployment": { - "transport": "http", - "log_level": "DEBUG" - } -} -``` - -**prod.fastmcp.json**: -```json -{ - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" - }, - "environment": { - "requirements": "requirements/production.txt" - }, - "deployment": { - "transport": "http", - "host": "0.0.0.0", - "log_level": "WARNING" - } -} -``` - -Run different configurations: -```bash -fastmcp run dev.fastmcp.json # Development -fastmcp run prod.fastmcp.json # Production -``` - - -## CLI Override Behavior - -Command-line arguments take precedence over configuration file values, allowing ad-hoc adjustments without modifying the file: - -```bash -# Config specifies port 3000, CLI overrides to 8080 -fastmcp run fastmcp.json --port 8080 - -# Config specifies stdio, CLI overrides to HTTP -fastmcp run fastmcp.json --transport http - -# Add extra dependencies not in config -fastmcp run fastmcp.json --with requests --with httpx -``` - -This precedence order enables: -- Quick testing of different settings -- Environment-specific overrides in deployment scripts -- Debugging with increased log levels -- Temporary configuration changes - -## Best Practices - -When using `fastmcp.json` for your projects, consider these recommendations: - -**Version Control**: Always commit your `fastmcp.json` to version control. It's essential project documentation that ensures others can run your server correctly. - -**Environment Variables**: Use the `env` field for configuration values instead of hardcoding them in your Python code. For sensitive values, consider using environment variable references or separate secret management. - -**Dependency Management**: Specify exact versions for production dependencies to ensure reproducible builds: -```json -{ - "dependencies": [ - "pandas==2.1.0", - "requests==2.31.0" - ] -} -``` - -**Path Resolution**: Remember that paths in the configuration are relative to the config file location. Use relative paths for portability: -```json -{ - "entrypoint": "./src/server.py", - "environment": { - "requirements": "./requirements.txt" - } -} -``` - -**Development Workflow**: Use separate configuration files for different environments rather than constantly modifying a single file. The CLI's override behavior makes it easy to switch between configurations. - -### Environment Variable Interpolation +#### Environment Variable Interpolation The `env` field in deployment configuration supports runtime interpolation of environment variables using `${VAR_NAME}` syntax. This enables dynamic configuration based on your deployment environment: @@ -499,6 +290,266 @@ This feature is particularly useful for: - Building dynamic URLs and connection strings - Creating environment-specific prefixes or suffixes +## Usage with CLI Commands + +FastMCP automatically detects and uses a file specifically named `fastmcp.json` in the current directory, making server execution simple and consistent. Files with FastMCP configuration format but different names are not auto-detected and must be specified explicitly: + +```bash +# Auto-detect fastmcp.json in current directory +cd my-project +fastmcp run # No arguments needed! + +# Or specify a configuration file explicitly +fastmcp run prod.fastmcp.json + +# Skip environment setup when already in a uv environment +fastmcp run fastmcp.json --skip-env + +# Skip source preparation when source is already prepared +fastmcp run fastmcp.json --skip-source + +# Skip both environment and source preparation +fastmcp run fastmcp.json --skip-env --skip-source +``` + +### Using an Existing Environment + +By default, FastMCP creates an isolated environment with `uv` based on your configuration. When you already have a suitable Python environment, use the `--skip-env` flag to skip environment creation: + +```bash +fastmcp run fastmcp.json --skip-env +``` + +**When you already have an environment:** +- You're in an activated virtual environment with all dependencies installed +- You're inside a Docker container with pre-installed dependencies +- You're in a CI/CD pipeline that pre-builds the environment +- You're using a system-wide installation with all required packages +- You're in a uv-managed environment (prevents infinite recursion) + +This flag tells FastMCP: "I already have everything installed, just run the server." + +### Using an Existing Source + +When working with source types that require preparation (future support for git repositories or cloud sources), use the `--skip-source` flag when you already have the source code available: + +```bash +fastmcp run fastmcp.json --skip-source +``` + +**When you already have the source:** +- You've previously cloned a git repository and don't need to re-fetch +- You have a cached copy of a cloud-hosted server +- You're in a CI/CD pipeline where source checkout is a separate step +- You're iterating locally on already-downloaded code + +This flag tells FastMCP: "I already have the source code, skip any download/clone steps." + +Note: For filesystem sources (local Python files), this flag has no effect since they don't require preparation. + +The configuration file works with all FastMCP commands: +- **`run`** - Start the server in production mode +- **`dev`** - Launch with the Inspector UI for development +- **`inspect`** - View server capabilities and configuration +- **`install`** - Install to Claude Desktop, Cursor, or other MCP clients + +When no file argument is provided, FastMCP searches the current directory for `fastmcp.json`. This means you can simply navigate to your project directory and run `fastmcp run` to start your server with all its configured settings. + +### CLI Override Behavior + +Command-line arguments take precedence over configuration file values, allowing ad-hoc adjustments without modifying the file: + +```bash +# Config specifies port 3000, CLI overrides to 8080 +fastmcp run fastmcp.json --port 8080 + +# Config specifies stdio, CLI overrides to HTTP +fastmcp run fastmcp.json --transport http + +# Add extra dependencies not in config +fastmcp run fastmcp.json --with requests --with httpx +``` + +This precedence order enables: +- Quick testing of different settings +- Environment-specific overrides in deployment scripts +- Debugging with increased log levels +- Temporary configuration changes + +### Custom Naming Patterns + +You can use different configuration files for different environments: + +- `fastmcp.json` - Default configuration +- `dev.fastmcp.json` - Development settings +- `prod.fastmcp.json` - Production settings +- `test_fastmcp.json` - Test configuration + +Any file with "fastmcp.json" in the name is recognized as a configuration file. + +## Examples + + + + +A minimal configuration for a simple server: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" + } +} +``` +This configuration explicitly specifies the server entrypoint (`mcp`), making it clear which server instance or factory function to use. Uses all defaults: STDIO transport, no special dependencies, standard logging. + + + +A configuration optimized for local development: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + // WHERE does the server live? + "source": { + "path": "src/server.py", + "entrypoint": "app" + }, + // WHAT dependencies does it need? + "environment": { + "python": "3.12", + "dependencies": ["fastmcp[dev]"], + "editable": "." + }, + // HOW should it run? + "deployment": { + "transport": "http", + "host": "127.0.0.1", + "port": 8000, + "log_level": "DEBUG", + "env": { + "DEBUG": "true", + "ENV": "development" + } + } +} +``` + + + +A production-ready configuration with full dependency management: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + // WHERE does the server live? + "source": { + "path": "app/main.py", + "entrypoint": "mcp_server" + }, + // WHAT dependencies does it need? + "environment": { + "python": "3.11", + "requirements": "requirements/production.txt", + "project": "." + }, + // HOW should it run? + "deployment": { + "transport": "http", + "host": "0.0.0.0", + "port": 3000, + "path": "/api/mcp/", + "log_level": "INFO", + "env": { + "ENV": "production", + "API_BASE_URL": "https://api.example.com", + "DATABASE_URL": "postgresql://user:pass@db.example.com/prod" + }, + "cwd": "/app", + "args": ["--workers", "4"] + } +} +``` + + + +Configuration for a data analysis server with scientific packages: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "analysis_server.py", + "entrypoint": "mcp" + }, + "environment": { + "python": "3.11", + "dependencies": [ + "pandas>=2.0", + "numpy", + "scikit-learn", + "matplotlib", + "jupyterlab" + ] + }, + "deployment": { + "transport": "stdio", + "env": { + "MATPLOTLIB_BACKEND": "Agg", + "DATA_PATH": "./datasets" + } + } +} +``` + + + +You can maintain multiple configuration files for different environments: + +**dev.fastmcp.json**: +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" + }, + "deployment": { + "transport": "http", + "log_level": "DEBUG" + } +} +``` + +**prod.fastmcp.json**: +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" + }, + "environment": { + "requirements": "requirements/production.txt" + }, + "deployment": { + "transport": "http", + "host": "0.0.0.0", + "log_level": "WARNING" + } +} +``` + +Run different configurations: +```bash +fastmcp run dev.fastmcp.json # Development +fastmcp run prod.fastmcp.json # Production +``` + + + ## Migrating from CLI Arguments If you're currently using command-line arguments or shell scripts, migrating to `fastmcp.json` simplifies your workflow. Here's how common CLI patterns map to configuration: @@ -515,10 +566,10 @@ uv run --with pandas --with requests \ **Equivalent fastmcp.json**: ```json { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] diff --git a/docs/docs.json b/docs/docs.json index f52693eda..1054a0289 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -119,7 +119,10 @@ { "group": "Essentials", "icon": "cube", - "pages": ["clients/client", "clients/transports"] + "pages": [ + "clients/client", + "clients/transports" + ] }, { "group": "Core Operations", @@ -145,7 +148,10 @@ { "group": "Authentication", "icon": "user-shield", - "pages": ["clients/auth/oauth", "clients/auth/bearer"] + "pages": [ + "clients/auth/oauth", + "clients/auth/bearer" + ] } ] }, @@ -224,12 +230,17 @@ }, { "anchor": "What's New", - "pages": ["updates", "changelog"] + "pages": [ + "updates", + "changelog" + ] }, { "anchor": "Community", "icon": "users", - "pages": ["community/showcase"] + "pages": [ + "community/showcase" + ] } ], "tab": "Documentation" @@ -327,6 +338,7 @@ "python-sdk/fastmcp-server-auth-providers-workos" ] }, + "python-sdk/fastmcp-server-auth-redirect_validation", "python-sdk/fastmcp-server-auth-registry" ] }, @@ -367,6 +379,7 @@ "python-sdk/fastmcp-utilities-cli", "python-sdk/fastmcp-utilities-components", "python-sdk/fastmcp-utilities-exceptions", + "python-sdk/fastmcp-utilities-fastmcp_config", "python-sdk/fastmcp-utilities-http", "python-sdk/fastmcp-utilities-inspect", "python-sdk/fastmcp-utilities-json_schema", diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index 62e0ef29a..2b04c10a8 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -85,10 +85,10 @@ Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] diff --git a/docs/integrations/claude-desktop.mdx b/docs/integrations/claude-desktop.mdx index 20c42d93d..3158a005a 100644 --- a/docs/integrations/claude-desktop.mdx +++ b/docs/integrations/claude-desktop.mdx @@ -102,10 +102,10 @@ Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] diff --git a/docs/integrations/cursor.mdx b/docs/integrations/cursor.mdx index b648aee48..344ddc52f 100644 --- a/docs/integrations/cursor.mdx +++ b/docs/integrations/cursor.mdx @@ -103,10 +103,10 @@ Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] diff --git a/docs/integrations/mcp-json-configuration.mdx b/docs/integrations/mcp-json-configuration.mdx index 654d6b041..99e44fb85 100644 --- a/docs/integrations/mcp-json-configuration.mdx +++ b/docs/integrations/mcp-json-configuration.mdx @@ -178,10 +178,10 @@ You can also use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "matplotlib", "seaborn"] diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 56cc91908..654f54766 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -46,6 +46,7 @@ By default, this command runs the server directly in your current Python environ | Path | `--path` | Path to bind to when using http transport (default: `/mcp/` or `/sse/` for SSE) | | Log Level | `--log-level`, `-l` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | | No Banner | `--no-banner` | Disable the startup banner display | +| No Environment | `--skip-env` | Skip environment setup with uv (use when already in a uv environment) | | Python Version | `--python` | Python version to use (e.g., 3.10, 3.11) | | Additional Packages | `--with` | Additional packages to install (can be used multiple times) | | Project Directory | `--project` | Run the command within the given project directory | @@ -57,8 +58,8 @@ By default, this command runs the server directly in your current Python environ The `fastmcp run` command supports the following entrypoints: -1. **[Inferred server instance](#inferred-server-instance)**: `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. -2. **[Explicit server object](#explicit-server-object)**: `server.py:custom_name` - imports and uses the specified server object +1. **[Inferred server instance](#inferred-server-instance)**: `server.py` - imports the module and looks for a FastMCP server instance named `mcp`, `server`, or `app`. Errors if no such object is found. +2. **[Explicit server entrypoint](#explicit-server-entrypoint)**: `server.py:custom_name` - imports and uses the specified server entrypoint 3. **[Factory function](#factory-function)**: `server.py:create_server` - calls the specified function (sync or async) to create a server instance 4. **[Remote server proxy](#remote-server-proxy)**: `https://example.com/mcp-server` - connects to a remote server and creates a **local proxy server** 5. **[FastMCP configuration file](#fastmcp-configuration)**: `fastmcp.json` - runs servers using FastMCP's declarative configuration format (auto-detects files in current directory) @@ -68,7 +69,7 @@ The `fastmcp run` command supports the following entrypoints: Note: When using `fastmcp run` with a local file, it **completely ignores** the `if __name__ == "__main__"` block. This means: - Any setup code in `__main__` will NOT run - Server configuration in `__main__` is bypassed -- `fastmcp run` finds your server object/factory and runs it with its own transport settings +- `fastmcp run` finds your server entrypoint/factory and runs it with its own transport settings If you need setup code to run, use the **factory pattern** instead. @@ -91,9 +92,9 @@ You can run it with: fastmcp run server.py ``` -#### Explicit Server Object +#### Explicit Server Entrypoint -If your server is stored as a variable with a custom name, or you want to be explicit about which server to run, you can use the following syntax to load a specific server object: +If your server is stored as a variable with a custom name, or you want to be explicit about which server to run, you can use the following syntax to load a specific server entrypoint: ```bash fastmcp run server.py:custom_name @@ -122,7 +123,7 @@ fastmcp run server.py:custom_name Since `fastmcp run` ignores the `if __name__ == "__main__"` block, you can use a factory function to run setup code before your server starts. Factory functions are called without any arguments and must return a FastMCP server instance. Both sync and async factory functions are supported. -The syntax for using a factory function is the same as for an explicit server object: `fastmcp run server.py:factory_fn`. FastMCP will automatically detect that you have identified a function rather than a server Instance +The syntax for using a factory function is the same as for an explicit server entrypoint: `fastmcp run server.py:factory_fn`. FastMCP will automatically detect that you have identified a function rather than a server Instance For example, if you have a file called `server.py` with the following content: @@ -177,8 +178,19 @@ The configuration file handles dependencies, environment variables, and transpor ```bash # Override port from config file fastmcp run fastmcp.json --port 8080 + +# Skip environment setup when already in a uv environment +fastmcp run fastmcp.json --skip-env ``` + +The `--skip-env` flag is useful when: +- You're already in an activated virtual environment +- You're inside a Docker container with pre-installed dependencies +- You're in a uv-managed environment (prevents infinite recursion) +- You want to test the server without environment setup + + See [Server Configuration](/deployment/server-configuration) for detailed documentation on fastmcp.json. #### MCP Configuration @@ -244,8 +256,8 @@ This command does not support HTTP testing. To test a server over Streamable HTT The `dev` command supports local FastMCP server files and configuration: -1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. -2. **Explicit server object**: `server.py:custom_name` - imports and uses the specified server object +1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP server instance named `mcp`, `server`, or `app`. Errors if no such object is found. +2. **Explicit server entrypoint**: `server.py:custom_name` - imports and uses the specified server entrypoint 3. **Factory function**: `server.py:create_server` - calls the specified function (sync or async) to create a server instance 4. **FastMCP configuration**: `fastmcp.json` - uses FastMCP's declarative configuration (auto-detects in current directory) @@ -323,8 +335,8 @@ Note that for security reasons, MCP clients usually run every server in a comple The `install` command supports local FastMCP server files and configuration: -1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. -2. **Explicit server object**: `server.py:custom_name` - imports and uses the specified server object +1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP server instance named `mcp`, `server`, or `app`. Errors if no such object is found. +2. **Explicit server entrypoint**: `server.py:custom_name` - imports and uses the specified server entrypoint 3. **Factory function**: `server.py:create_server` - calls the specified function (sync or async) to create a server instance 4. **FastMCP configuration**: `fastmcp.json` - uses FastMCP's declarative configuration with dependencies and settings @@ -339,7 +351,7 @@ The `install` command **only supports local files and fastmcp.json** - no URLs, **Examples** ```bash -# Auto-detects server object (looks for 'mcp', 'server', or 'app') +# Auto-detects server entrypoint (looks for 'mcp', 'server', or 'app') fastmcp install claude-desktop server.py # Install with fastmcp.json configuration (auto-detects) @@ -348,7 +360,7 @@ fastmcp install claude-desktop # Install with explicit fastmcp.json file fastmcp install claude-desktop my-config.fastmcp.json -# Uses specific server object +# Uses specific server entrypoint fastmcp install claude-desktop server.py:my_server # With custom name and dependencies @@ -439,8 +451,8 @@ fastmcp inspect server.py The `inspect` command supports local FastMCP server files and configuration: -1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. -2. **Explicit server object**: `server.py:custom_name` - imports and uses the specified server object +1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP server instance named `mcp`, `server`, or `app`. Errors if no such object is found. +2. **Explicit server entrypoint**: `server.py:custom_name` - imports and uses the specified server entrypoint 3. **Factory function**: `server.py:create_server` - calls the specified function (sync or async) to create a server instance 4. **FastMCP configuration**: `fastmcp.json` - inspects servers defined with FastMCP's declarative configuration @@ -451,10 +463,10 @@ The `inspect` command **only supports local files and fastmcp.json** - no URLs, **Examples** ```bash -# Auto-detect server object +# Auto-detect server entrypoint fastmcp inspect server.py -# Specify server object +# Specify server entrypoint fastmcp inspect server.py:my_server # Custom output location diff --git a/docs/public/schemas/fastmcp.json/latest.json b/docs/public/schemas/fastmcp.json/latest.json new file mode 100644 index 000000000..81d0ad754 --- /dev/null +++ b/docs/public/schemas/fastmcp.json/latest.json @@ -0,0 +1,348 @@ +{ + "$defs": { + "Deployment": { + "description": "Configuration for server deployment and runtime settings.", + "properties": { + "transport": { + "anyOf": [ + { + "enum": [ + "stdio", + "http", + "sse" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Transport protocol to use", + "title": "Transport" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Host to bind to when using HTTP transport", + "examples": [ + "127.0.0.1", + "0.0.0.0", + "localhost" + ], + "title": "Host" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Port to bind to when using HTTP transport", + "examples": [ + 8000, + 3000, + 5000 + ], + "title": "Port" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "URL path for the server endpoint", + "examples": [ + "/mcp/", + "/api/mcp/", + "/sse/" + ], + "title": "Path" + }, + "log_level": { + "anyOf": [ + { + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Log level for the server", + "title": "Log Level" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Working directory for the server process", + "examples": [ + ".", + "./src", + "/app" + ], + "title": "Cwd" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Environment variables to set when running the server", + "examples": [ + { + "API_KEY": "secret", + "DEBUG": "true" + } + ], + "title": "Env" + }, + "args": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arguments to pass to the server (after --)", + "examples": [ + [ + "--config", + "config.json", + "--debug" + ] + ], + "title": "Args" + } + }, + "title": "Deployment", + "type": "object" + }, + "Environment": { + "description": "Configuration for Python environment setup.", + "properties": { + "python": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Python version constraint", + "examples": [ + "3.10", + "3.11", + "3.12" + ], + "title": "Python" + }, + "dependencies": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Python packages to install with PEP 508 specifiers", + "examples": [ + [ + "fastmcp>=2.0,<3", + "httpx", + "pandas>=2.0" + ] + ], + "title": "Dependencies" + }, + "requirements": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to requirements.txt file", + "examples": [ + "requirements.txt", + "../requirements/prod.txt" + ], + "title": "Requirements" + }, + "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to project directory containing pyproject.toml", + "examples": [ + ".", + "../my-project" + ], + "title": "Project" + }, + "editable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Directory to install in editable mode", + "examples": [ + ".", + "../my-package" + ], + "title": "Editable" + } + }, + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", + "type": "object" + } + }, + "description": "Configuration file for FastMCP servers", + "properties": { + "$schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "description": "JSON schema for IDE support and validation", + "title": "$Schema" + }, + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", + "examples": [ + { + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" + } + ] + }, + "environment": { + "$ref": "#/$defs/Environment", + "description": "Python environment setup configuration" + }, + "deployment": { + "$ref": "#/$defs/Deployment", + "description": "Server deployment and runtime settings" + } + }, + "required": [ + "source" + ], + "title": "FastMCP Configuration", + "type": "object", + "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" +} diff --git a/docs/public/schemas/fastmcp.json/v1.json b/docs/public/schemas/fastmcp.json/v1.json new file mode 100644 index 000000000..81d0ad754 --- /dev/null +++ b/docs/public/schemas/fastmcp.json/v1.json @@ -0,0 +1,348 @@ +{ + "$defs": { + "Deployment": { + "description": "Configuration for server deployment and runtime settings.", + "properties": { + "transport": { + "anyOf": [ + { + "enum": [ + "stdio", + "http", + "sse" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Transport protocol to use", + "title": "Transport" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Host to bind to when using HTTP transport", + "examples": [ + "127.0.0.1", + "0.0.0.0", + "localhost" + ], + "title": "Host" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Port to bind to when using HTTP transport", + "examples": [ + 8000, + 3000, + 5000 + ], + "title": "Port" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "URL path for the server endpoint", + "examples": [ + "/mcp/", + "/api/mcp/", + "/sse/" + ], + "title": "Path" + }, + "log_level": { + "anyOf": [ + { + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Log level for the server", + "title": "Log Level" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Working directory for the server process", + "examples": [ + ".", + "./src", + "/app" + ], + "title": "Cwd" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Environment variables to set when running the server", + "examples": [ + { + "API_KEY": "secret", + "DEBUG": "true" + } + ], + "title": "Env" + }, + "args": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arguments to pass to the server (after --)", + "examples": [ + [ + "--config", + "config.json", + "--debug" + ] + ], + "title": "Args" + } + }, + "title": "Deployment", + "type": "object" + }, + "Environment": { + "description": "Configuration for Python environment setup.", + "properties": { + "python": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Python version constraint", + "examples": [ + "3.10", + "3.11", + "3.12" + ], + "title": "Python" + }, + "dependencies": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Python packages to install with PEP 508 specifiers", + "examples": [ + [ + "fastmcp>=2.0,<3", + "httpx", + "pandas>=2.0" + ] + ], + "title": "Dependencies" + }, + "requirements": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to requirements.txt file", + "examples": [ + "requirements.txt", + "../requirements/prod.txt" + ], + "title": "Requirements" + }, + "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Path to project directory containing pyproject.toml", + "examples": [ + ".", + "../my-project" + ], + "title": "Project" + }, + "editable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Directory to install in editable mode", + "examples": [ + ".", + "../my-package" + ], + "title": "Editable" + } + }, + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", + "type": "object" + } + }, + "description": "Configuration file for FastMCP servers", + "properties": { + "$schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "description": "JSON schema for IDE support and validation", + "title": "$Schema" + }, + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", + "examples": [ + { + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" + } + ] + }, + "environment": { + "$ref": "#/$defs/Environment", + "description": "Python environment setup configuration" + }, + "deployment": { + "$ref": "#/$defs/Deployment", + "description": "Server deployment and runtime settings" + } + }, + "required": [ + "source" + ], + "title": "FastMCP Configuration", + "type": "object", + "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" +} diff --git a/docs/python-sdk/fastmcp-cli-claude.mdx b/docs/python-sdk/fastmcp-cli-claude.mdx index 37fcedbf2..6841a69dc 100644 --- a/docs/python-sdk/fastmcp-cli-claude.mdx +++ b/docs/python-sdk/fastmcp-cli-claude.mdx @@ -10,7 +10,7 @@ Claude app integration utilities. ## Functions -### `get_claude_config_path` +### `get_claude_config_path` ```python get_claude_config_path() -> Path | None @@ -20,7 +20,7 @@ get_claude_config_path() -> Path | None Get the Claude config directory based on platform. -### `update_claude_config` +### `update_claude_config` ```python update_claude_config(file_spec: str, server_name: str) -> bool diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index 749eb2616..f9d027f40 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `version` +### `version` ```python version() @@ -20,45 +20,47 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python -dev(server_spec: str) -> None +dev(server_spec: str | None = None) -> None ``` Run an MCP server with the MCP Inspector for development. **Args:** -- `server_spec`: Python file to run, optionally with \:object suffix +- `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python -run(server_spec: str, *server_args: str) -> None +run(server_spec: str | None = None, *server_args: str) -> None ``` Run an MCP server or connect to a remote one. -The server can be specified in four ways: +The server can be specified in several ways: 1. Module approach: "server.py" - runs the module directly, looking for an object named 'mcp', 'server', or 'app' 2. Import approach: "server.py:app" - imports and runs the specified server object 3. URL approach: "http://server-url" - connects to a remote server and creates a proxy 4. MCPConfig file: "mcp.json" - runs as a proxy server for the MCP Servers in the MCPConfig file +5. FastMCP config: "fastmcp.json" - runs server using FastMCP configuration +6. No argument: looks for fastmcp.json in current directory Server arguments can be passed after -- : fastmcp run server.py -- --config config.json --debug **Args:** -- `server_spec`: Python file, object specification (file\:obj), MCPConfig file, or URL +- `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python -inspect(server_spec: str) -> None +inspect(server_spec: str | None = None) -> None ``` @@ -74,7 +76,9 @@ fastmcp inspect server.py fastmcp inspect server.py -o report.json fastmcp inspect server.py:mcp -o analysis.json fastmcp inspect path/to/server.py:app -o /tmp/server-info.json +fastmcp inspect fastmcp.json +fastmcp inspect # auto-detect fastmcp.json **Args:** -- `server_spec`: Python file to inspect, optionally with \:object suffix +- `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json diff --git a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx index cd2c07592..d85264f05 100644 --- a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx +++ b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx @@ -10,7 +10,7 @@ Claude Code integration for FastMCP install using Cyclopts. ## Functions -### `find_claude_command` +### `find_claude_command` ```python find_claude_command() -> str | None @@ -23,7 +23,7 @@ Checks common installation locations since 'claude' is often a shell alias that doesn't work with subprocess calls. -### `check_claude_code_available` +### `check_claude_code_available` ```python check_claude_code_available() -> bool @@ -33,7 +33,7 @@ check_claude_code_available() -> bool Check if Claude Code CLI is available. -### `install_claude_code` +### `install_claude_code` ```python install_claude_code(file: Path, server_object: str | None, name: str) -> bool @@ -57,7 +57,7 @@ Install FastMCP server in Claude Code. - True if installation was successful, False otherwise -### `claude_code_command` +### `claude_code_command` ```python claude_code_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx index 4bd05ac95..fd14e02bd 100644 --- a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx +++ b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx @@ -10,7 +10,7 @@ Claude Desktop integration for FastMCP install using Cyclopts. ## Functions -### `get_claude_config_path` +### `get_claude_config_path` ```python get_claude_config_path() -> Path | None @@ -20,7 +20,7 @@ get_claude_config_path() -> Path | None Get the Claude config directory based on platform. -### `install_claude_desktop` +### `install_claude_desktop` ```python install_claude_desktop(file: Path, server_object: str | None, name: str) -> bool @@ -44,7 +44,7 @@ Install FastMCP server in Claude Desktop. - True if installation was successful, False otherwise -### `claude_desktop_command` +### `claude_desktop_command` ```python claude_desktop_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-cursor.mdx b/docs/python-sdk/fastmcp-cli-install-cursor.mdx index 2f2c9499a..c8d91ad7c 100644 --- a/docs/python-sdk/fastmcp-cli-install-cursor.mdx +++ b/docs/python-sdk/fastmcp-cli-install-cursor.mdx @@ -10,7 +10,7 @@ Cursor integration for FastMCP install using Cyclopts. ## Functions -### `generate_cursor_deeplink` +### `generate_cursor_deeplink` ```python generate_cursor_deeplink(server_name: str, server_config: StdioMCPServer) -> str @@ -27,7 +27,7 @@ Generate a Cursor deeplink for installing the MCP server. - Deeplink URL that can be clicked to install the server -### `open_deeplink` +### `open_deeplink` ```python open_deeplink(deeplink: str) -> bool @@ -43,7 +43,32 @@ Attempt to open a deeplink URL using the system's default handler. - True if the command succeeded, False otherwise -### `install_cursor` +### `install_cursor_workspace` + +```python +install_cursor_workspace(file: Path, server_object: str | None, name: str, workspace_path: Path) -> bool +``` + + +Install FastMCP server to workspace-specific Cursor configuration. + +**Args:** +- `file`: Path to the server file +- `server_object`: Optional server object name (for \:object suffix) +- `name`: Name for the server in Cursor +- `workspace_path`: Path to the workspace directory +- `with_editable`: Optional directory to install in editable mode +- `with_packages`: Optional list of additional packages to install +- `env_vars`: Optional dictionary of environment variables +- `python_version`: Optional Python version to use +- `with_requirements`: Optional requirements file to install from +- `project`: Optional project directory to run within + +**Returns:** +- True if installation was successful, False otherwise + + +### `install_cursor` ```python install_cursor(file: Path, server_object: str | None, name: str) -> bool @@ -62,12 +87,13 @@ Install FastMCP server in Cursor. - `python_version`: Optional Python version to use - `with_requirements`: Optional requirements file to install from - `project`: Optional project directory to run within +- `workspace`: Optional workspace directory for project-specific installation **Returns:** - True if installation was successful, False otherwise -### `cursor_command` +### `cursor_command` ```python cursor_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx b/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx index aecf21fa7..fb6f2a326 100644 --- a/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx +++ b/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx @@ -10,7 +10,7 @@ MCP configuration JSON generation for FastMCP install using Cyclopts. ## Functions -### `install_mcp_json` +### `install_mcp_json` ```python install_mcp_json(file: Path, server_object: str | None, name: str) -> bool @@ -35,7 +35,7 @@ Generate MCP configuration JSON for manual installation. - True if generation was successful, False otherwise -### `mcp_json_command` +### `mcp_json_command` ```python mcp_json_command(server_spec: str) -> None diff --git a/docs/python-sdk/fastmcp-cli-install-shared.mdx b/docs/python-sdk/fastmcp-cli-install-shared.mdx index 5279742eb..8e959d21e 100644 --- a/docs/python-sdk/fastmcp-cli-install-shared.mdx +++ b/docs/python-sdk/fastmcp-cli-install-shared.mdx @@ -10,7 +10,7 @@ Shared utilities for install commands. ## Functions -### `parse_env_var` +### `parse_env_var` ```python parse_env_var(env_var: str) -> tuple[str, str] @@ -20,7 +20,7 @@ parse_env_var(env_var: str) -> tuple[str, str] Parse environment variable string in format KEY=VALUE. -### `process_common_args` +### `process_common_args` ```python process_common_args(server_spec: str, server_name: str | None, with_packages: list[str], env_vars: list[str], env_file: Path | None) -> tuple[Path, str | None, str, list[str], dict[str, str] | None] @@ -29,3 +29,5 @@ process_common_args(server_spec: str, server_name: str | None, with_packages: li Process common arguments shared by all install commands. +Handles both fastmcp.json config files and traditional file.py:object syntax. + diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx index 0e48d7e93..74bb0b1a1 100644 --- a/docs/python-sdk/fastmcp-cli-run.mdx +++ b/docs/python-sdk/fastmcp-cli-run.mdx @@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints. ## Functions -### `is_url` +### `is_url` ```python is_url(path: str) -> bool @@ -20,7 +20,7 @@ is_url(path: str) -> bool Check if a string is a URL. -### `parse_file_path` +### `parse_file_path` ```python parse_file_path(server_spec: str) -> tuple[Path, str | None] @@ -36,7 +36,7 @@ Parse a file path that may include a server object specification. - Tuple of (file_path, server_object) -### `import_server` +### `import_server` ```python import_server(file: Path, server_or_factory: str | None = None) -> Any @@ -53,17 +53,17 @@ Import a MCP server from a file. - The server object (or result of calling a factory function) -### `run_with_uv` +### `run_with_uv` ```python -run_with_uv(server_spec: str, python_version: str | None = None, with_packages: list[str] | None = None, with_requirements: Path | None = None, project: Path | None = None, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, show_banner: bool = True) -> None +run_with_uv(server_spec: str, python_version: str | None = None, with_packages: list[str] | None = None, with_requirements: Path | None = None, project: Path | None = None, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, show_banner: bool = True, editable: str | None = None) -> None ``` Run a MCP server using uv run subprocess. **Args:** -- `server_spec`: Python file, object specification (file\:obj), or URL +- `server_spec`: Python file, object specification (file\:obj), config file, or URL - `python_version`: Python version to use (e.g. "3.10") - `with_packages`: Additional packages to install - `with_requirements`: Requirements file to use @@ -76,7 +76,7 @@ Run a MCP server using uv run subprocess. - `show_banner`: Whether to show the server banner -### `create_client_server` +### `create_client_server` ```python create_client_server(url: str) -> Any @@ -92,7 +92,7 @@ Create a FastMCP server from a client URL. - A FastMCP server instance -### `create_mcp_config_server` +### `create_mcp_config_server` ```python create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] @@ -102,7 +102,23 @@ create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] Create a FastMCP server from a MCPConfig. -### `import_server_with_args` +### `load_fastmcp_config` + +```python +load_fastmcp_config(config_path: Path) -> FastMCPConfig +``` + + +Load a FastMCP configuration from a fastmcp.json file. + +**Args:** +- `config_path`: Path to fastmcp.json file + +**Returns:** +- FastMCPConfig object + + +### `import_server_with_args` ```python import_server_with_args(file: Path, server_or_factory: str | None = None, server_args: list[str] | None = None) -> Any @@ -120,7 +136,7 @@ Import a server with optional command line arguments. - The imported server object -### `run_command` +### `run_command` ```python run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False) -> None @@ -130,7 +146,7 @@ run_command(server_spec: str, transport: TransportType | None = None, host: str Run a MCP server or connect to a remote one. **Args:** -- `server_spec`: Python file, object specification (file\:obj), MCPConfig file, or URL +- `server_spec`: Python file, object specification (file\:obj), config file, or URL - `transport`: Transport protocol to use - `host`: Host to bind to when using http transport - `port`: Port to bind to when using http transport @@ -141,7 +157,7 @@ Run a MCP server or connect to a remote one. - `use_direct_import`: Whether to use direct import instead of subprocess -### `run_v1_server` +### `run_v1_server` ```python run_v1_server(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index 30c063b93..0b261f9b0 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -122,7 +122,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -131,7 +131,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx index a1ff804bf..406d0c329 100644 --- a/docs/python-sdk/fastmcp-client-client.mdx +++ b/docs/python-sdk/fastmcp-client-client.mdx @@ -7,7 +7,7 @@ sidebarTitle: client ## Classes -### `ClientSessionState` +### `ClientSessionState` Holds all session-related state for a Client instance. @@ -16,7 +16,7 @@ This allows clean separation of configuration (which is copied) from session state (which should be fresh for each new client instance). -### `Client` +### `Client` MCP client that delegates connection management to a Transport instance. @@ -79,7 +79,7 @@ async with client: **Methods:** -#### `session` +#### `session` ```python session(self) -> ClientSession @@ -88,7 +88,7 @@ session(self) -> ClientSession Get the current active session. Raises RuntimeError if not connected. -#### `initialize_result` +#### `initialize_result` ```python initialize_result(self) -> mcp.types.InitializeResult @@ -97,7 +97,7 @@ initialize_result(self) -> mcp.types.InitializeResult Get the result of the initialization request. -#### `set_roots` +#### `set_roots` ```python set_roots(self, roots: RootsList | RootsHandler) -> None @@ -106,16 +106,16 @@ set_roots(self, roots: RootsList | RootsHandler) -> None Set the roots for the client. This does not automatically call `send_roots_list_changed`. -#### `set_sampling_callback` +#### `set_sampling_callback` ```python -set_sampling_callback(self, sampling_callback: SamplingHandler) -> None +set_sampling_callback(self, sampling_callback: ClientSamplingHandler) -> None ``` Set the sampling callback for the client. -#### `set_elicitation_callback` +#### `set_elicitation_callback` ```python set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None @@ -124,7 +124,7 @@ set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None Set the elicitation callback for the client. -#### `is_connected` +#### `is_connected` ```python is_connected(self) -> bool @@ -133,7 +133,7 @@ is_connected(self) -> bool Check if the client is currently connected. -#### `new` +#### `new` ```python new(self) -> Client[ClientTransportT] @@ -149,13 +149,13 @@ share state with the original client. - A new Client instance with the same configuration but disconnected state. -#### `close` +#### `close` ```python close(self) ``` -#### `ping` +#### `ping` ```python ping(self) -> bool @@ -164,7 +164,7 @@ ping(self) -> bool Send a ping request. -#### `cancel` +#### `cancel` ```python cancel(self, request_id: str | int, reason: str | None = None) -> None @@ -173,7 +173,7 @@ cancel(self, request_id: str | int, reason: str | None = None) -> None Send a cancellation notification for an in-progress request. -#### `progress` +#### `progress` ```python progress(self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None) -> None @@ -182,7 +182,7 @@ progress(self, progress_token: str | int, progress: float, total: float | None = Send a progress notification. -#### `set_logging_level` +#### `set_logging_level` ```python set_logging_level(self, level: mcp.types.LoggingLevel) -> None @@ -191,7 +191,7 @@ set_logging_level(self, level: mcp.types.LoggingLevel) -> None Send a logging/setLevel request. -#### `send_roots_list_changed` +#### `send_roots_list_changed` ```python send_roots_list_changed(self) -> None @@ -200,7 +200,7 @@ send_roots_list_changed(self) -> None Send a roots/list_changed notification. -#### `list_resources_mcp` +#### `list_resources_mcp` ```python list_resources_mcp(self) -> mcp.types.ListResourcesResult @@ -216,7 +216,7 @@ containing the list of resources and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> list[mcp.types.Resource] @@ -231,7 +231,7 @@ Retrieve a list of resources available on the server. - `RuntimeError`: If called while the client is not connected. -#### `list_resource_templates_mcp` +#### `list_resource_templates_mcp` ```python list_resource_templates_mcp(self) -> mcp.types.ListResourceTemplatesResult @@ -247,7 +247,7 @@ containing the list of resource templates and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> list[mcp.types.ResourceTemplate] @@ -262,7 +262,7 @@ Retrieve a list of resource templates available on the server. - `RuntimeError`: If called while the client is not connected. -#### `read_resource_mcp` +#### `read_resource_mcp` ```python read_resource_mcp(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult @@ -281,7 +281,7 @@ containing the resource contents and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] @@ -300,7 +300,7 @@ objects, typically containing either text or binary data. - `RuntimeError`: If called while the client is not connected. -#### `list_prompts_mcp` +#### `list_prompts_mcp` ```python list_prompts_mcp(self) -> mcp.types.ListPromptsResult @@ -316,7 +316,7 @@ containing the list of prompts and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> list[mcp.types.Prompt] @@ -331,7 +331,7 @@ Retrieve a list of prompts available on the server. - `RuntimeError`: If called while the client is not connected. -#### `get_prompt_mcp` +#### `get_prompt_mcp` ```python get_prompt_mcp(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult @@ -351,7 +351,7 @@ containing the prompt messages and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult @@ -371,7 +371,7 @@ containing the prompt messages and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `complete_mcp` +#### `complete_mcp` ```python complete_mcp(self, ref: mcp.types.ResourceReference | mcp.types.PromptReference, argument: dict[str, str]) -> mcp.types.CompleteResult @@ -391,7 +391,7 @@ containing the completion and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `complete` +#### `complete` ```python complete(self, ref: mcp.types.ResourceReference | mcp.types.PromptReference, argument: dict[str, str]) -> mcp.types.Completion @@ -410,7 +410,7 @@ Send a completion request to the server. - `RuntimeError`: If called while the client is not connected. -#### `list_tools_mcp` +#### `list_tools_mcp` ```python list_tools_mcp(self) -> mcp.types.ListToolsResult @@ -426,7 +426,7 @@ containing the list of tools and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> list[mcp.types.Tool] @@ -441,7 +441,7 @@ Retrieve a list of tools available on the server. - `RuntimeError`: If called while the client is not connected. -#### `call_tool_mcp` +#### `call_tool_mcp` ```python call_tool_mcp(self, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.CallToolResult @@ -466,7 +466,7 @@ containing the tool result and any additional metadata. - `RuntimeError`: If called while the client is not connected. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True) -> CallToolResult @@ -496,4 +496,10 @@ raw result object. - `RuntimeError`: If called while the client is not connected. -### `CallToolResult` +#### `generate_name` + +```python +generate_name(cls, name: str | None = None) -> str +``` + +### `CallToolResult` diff --git a/docs/python-sdk/fastmcp-client-sampling.mdx b/docs/python-sdk/fastmcp-client-sampling.mdx index ac90fdedd..5fa3e70e1 100644 --- a/docs/python-sdk/fastmcp-client-sampling.mdx +++ b/docs/python-sdk/fastmcp-client-sampling.mdx @@ -7,8 +7,8 @@ sidebarTitle: sampling ## Functions -### `create_sampling_callback` +### `create_sampling_callback` ```python -create_sampling_callback(sampling_handler: SamplingHandler) -> SamplingFnT +create_sampling_callback(sampling_handler: ClientSamplingHandler[LifespanContextT]) -> SamplingFnT ``` diff --git a/docs/python-sdk/fastmcp-client-transports.mdx b/docs/python-sdk/fastmcp-client-transports.mdx index 009289bac..af31fb50e 100644 --- a/docs/python-sdk/fastmcp-client-transports.mdx +++ b/docs/python-sdk/fastmcp-client-transports.mdx @@ -7,7 +7,7 @@ sidebarTitle: transports ## Functions -### `infer_transport` +### `infer_transport` ```python infer_transport(transport: ClientTransport | FastMCP | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str) -> ClientTransport @@ -57,13 +57,13 @@ transport = infer_transport(config) ## Classes -### `SessionKwargs` +### `SessionKwargs` Keyword arguments for the MCP ClientSession constructor. -### `ClientTransport` +### `ClientTransport` Abstract base class for different MCP client transport mechanisms. @@ -74,7 +74,7 @@ to an MCP server, and providing a ClientSession within an async context. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] @@ -93,7 +93,7 @@ within this context. constructor (e.g., callbacks, timeouts). -#### `close` +#### `close` ```python close(self) @@ -102,7 +102,7 @@ close(self) Close the transport. -### `WSTransport` +### `WSTransport` Transport implementation that connects to an MCP server via WebSockets. @@ -110,13 +110,13 @@ Transport implementation that connects to an MCP server via WebSockets. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -### `SSETransport` +### `SSETransport` Transport implementation that connects to an MCP server via Server-Sent Events. @@ -124,13 +124,13 @@ Transport implementation that connects to an MCP server via Server-Sent Events. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -### `StreamableHttpTransport` +### `StreamableHttpTransport` Transport implementation that connects to an MCP server via Streamable HTTP Requests. @@ -138,7 +138,7 @@ Transport implementation that connects to an MCP server via Streamable HTTP Requ **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] @@ -179,43 +179,43 @@ disconnect(self) close(self) ``` -### `PythonStdioTransport` +### `PythonStdioTransport` Transport for running Python scripts. -### `FastMCPStdioTransport` +### `FastMCPStdioTransport` Transport for running FastMCP servers using the FastMCP CLI. -### `NodeStdioTransport` +### `NodeStdioTransport` Transport for running Node.js scripts. -### `UvStdioTransport` +### `UvStdioTransport` Transport for running commands via the uv tool. -### `UvxStdioTransport` +### `UvxStdioTransport` Transport for running commands via the uvx tool. -### `NpxStdioTransport` +### `NpxStdioTransport` Transport for running commands via the npx tool. -### `FastMCPTransport` +### `FastMCPTransport` In-memory transport for FastMCP servers. @@ -228,13 +228,13 @@ tests or scenarios where client and server run in the same runtime. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -### `MCPConfigTransport` +### `MCPConfigTransport` Transport for connecting to one or more MCP servers defined in an MCPConfig. @@ -287,13 +287,13 @@ async with client: **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -#### `close` +#### `close` ```python close(self) diff --git a/docs/python-sdk/fastmcp-mcp_config.mdx b/docs/python-sdk/fastmcp-mcp_config.mdx index 64d447bf3..805427bb5 100644 --- a/docs/python-sdk/fastmcp-mcp_config.mdx +++ b/docs/python-sdk/fastmcp-mcp_config.mdx @@ -32,7 +32,7 @@ Example configuration: ## Functions -### `infer_transport_type_from_url` +### `infer_transport_type_from_url` ```python infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse'] @@ -42,7 +42,7 @@ infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse'] Infer the appropriate transport type from the given URL. -### `update_config_file` +### `update_config_file` ```python update_config_file(file_path: Path, server_name: str, server_config: CanonicalMCPServerTypes) -> None @@ -57,7 +57,7 @@ worry about transforming server objects here. ## Classes -### `StdioMCPServer` +### `StdioMCPServer` MCP server configuration for stdio transport. @@ -67,19 +67,19 @@ This is the canonical configuration format for MCP servers using stdio transport **Methods:** -#### `to_transport` +#### `to_transport` ```python to_transport(self) -> StdioTransport ``` -### `TransformingStdioMCPServer` +### `TransformingStdioMCPServer` A Stdio server with tool transforms. -### `RemoteMCPServer` +### `RemoteMCPServer` MCP server configuration for HTTP/SSE transport. @@ -89,19 +89,19 @@ This is the canonical configuration format for MCP servers using remote transpor **Methods:** -#### `to_transport` +#### `to_transport` ```python to_transport(self) -> StreamableHttpTransport | SSETransport ``` -### `TransformingRemoteMCPServer` +### `TransformingRemoteMCPServer` A Remote server with tool transforms. -### `MCPConfig` +### `MCPConfig` A configuration object for MCP Servers that conforms to the canonical MCP configuration format @@ -113,16 +113,16 @@ For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class. **Methods:** -#### `validate_mcp_servers` +#### `wrap_servers_at_root` ```python -validate_mcp_servers(self, info: ValidationInfo) -> dict[str, Any] +wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] ``` -Validate the MCP servers. +If there's no mcpServers key but there are server configs at root, wrap them. -#### `add_server` +#### `add_server` ```python add_server(self, name: str, server: MCPServerTypes) -> None @@ -131,7 +131,7 @@ add_server(self, name: str, server: MCPServerTypes) -> None Add or update a server in the configuration. -#### `from_dict` +#### `from_dict` ```python from_dict(cls, config: dict[str, Any]) -> Self @@ -140,7 +140,7 @@ from_dict(cls, config: dict[str, Any]) -> Self Parse MCP configuration from dictionary format. -#### `to_dict` +#### `to_dict` ```python to_dict(self) -> dict[str, Any] @@ -149,7 +149,7 @@ to_dict(self) -> dict[str, Any] Convert MCPConfig to dictionary format, preserving all fields. -#### `write_to_file` +#### `write_to_file` ```python write_to_file(self, file_path: Path) -> None @@ -158,7 +158,7 @@ write_to_file(self, file_path: Path) -> None Write configuration to JSON file. -#### `from_file` +#### `from_file` ```python from_file(cls, file_path: Path) -> Self @@ -167,7 +167,7 @@ from_file(cls, file_path: Path) -> Self Load configuration from JSON file. -### `CanonicalMCPConfig` +### `CanonicalMCPConfig` Canonical MCP configuration format. @@ -178,7 +178,7 @@ The format is designed to be client-agnostic and extensible for future use cases **Methods:** -#### `add_server` +#### `add_server` ```python add_server(self, name: str, server: CanonicalMCPServerTypes) -> None diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index 1debc786a..84b2d4123 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -26,10 +26,10 @@ production use with enterprise identity providers. ## Classes -### `ProxyDCRClient` +### `ProxyDCRClient` -Client for DCR proxy that accepts any localhost redirect URI. +Client for DCR proxy with configurable redirect URI validation. This special client class is critical for the OAuth proxy to work correctly with Dynamic Client Registration (DCR). Here's why it exists: @@ -38,39 +38,39 @@ Problem: -------- When MCP clients use OAuth, they dynamically register with random localhost ports (e.g., http://localhost:55454/callback). The OAuth proxy needs to: -1. Accept these dynamic redirect URIs from clients +1. Accept these dynamic redirect URIs from clients based on configured patterns 2. Use its own fixed redirect URI with the upstream provider (Google, GitHub, etc.) 3. Forward the authorization code back to the client's dynamic URI Solution: --------- -This class overrides redirect_uri validation to accept ANY localhost URI, +This class validates redirect URIs against configurable patterns, while the proxy internally uses its own fixed redirect URI with the upstream provider. This allows the flow to work even when clients reconnect with different ports or when tokens are cached. -Without this class, clients would get "Redirect URI not registered" errors -when trying to authenticate with cached tokens, because the stored client -would have fixed redirect URIs that don't match the new dynamic port. +Without proper validation, clients could get "Redirect URI not registered" errors +when trying to authenticate with cached tokens, or security vulnerabilities could +arise from accepting arbitrary redirect URIs. **Methods:** -#### `validate_redirect_uri` +#### `validate_redirect_uri` ```python validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl ``` -Accept any localhost redirect URI for DCR clients. +Validate redirect URI against allowed patterns. Since we're acting as a proxy and clients register dynamically, -we need to accept their localhost redirect URIs even though they're -not pre-registered with us. This is essential for cached token -scenarios where the client may reconnect with a different port. +we validate their redirect URIs against configurable patterns. +This is essential for cached token scenarios where the client may +reconnect with a different port. -### `OAuthProxy` +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -182,7 +182,7 @@ Handles provider-specific requirements: **Methods:** -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -199,7 +199,7 @@ handles the case where a client with cached tokens reconnects on a different port. -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -226,7 +226,7 @@ The flow: 4. When client reconnects with a different port, ProxyDCRClient accepts it -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -240,7 +240,7 @@ This implements the DCR-compliant proxy pattern: 3. Redirect to IdP with our fixed callback URL -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -252,7 +252,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -264,7 +264,7 @@ For the DCR-compliant proxy flow, we return the IdP tokens that were obtained during the IdP callback exchange. PKCE validation is handled by the MCP framework. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -273,7 +273,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) Load refresh token from local storage. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -282,7 +282,7 @@ exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: Exchange refresh token for new access token using authlib. -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -294,7 +294,7 @@ Delegates to the JWT verifier which handles signature validation, expiration checking, and claims validation using the upstream JWKS. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -306,7 +306,7 @@ Removes tokens from local storage and attempts to revoke them with the upstream server if a revocation endpoint is configured. -#### `get_routes` +#### `get_routes` ```python get_routes(self) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx index 9f83a21ab..b8af2f930 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx @@ -69,23 +69,26 @@ Settings for JWT token verification. ### `JWTVerifier` -JWT token verifier using public key or JWKS. +JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. -This verifier validates JWT tokens signed by an external issuer. It's ideal for -scenarios where you have a centralized identity provider (like Auth0, Okta, or -your own OAuth server) that issues JWTs, and your FastMCP server acts as a -resource server validating those tokens. +This verifier validates JWT tokens using various signing algorithms: +- **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512): + Uses public/private key pairs. Ideal for external clients and services where + only the authorization server has the private key. +- **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both + signing and verification. Perfect for internal microservices and trusted + environments where the secret can be securely shared. Use this when: -- You have JWT tokens issued by an external service -- You want asymmetric key verification (public/private key pairs) -- You need JWKS support for automatic key rotation +- You have JWT tokens issued by an external service (asymmetric) +- You need JWKS support for automatic key rotation (asymmetric) +- You have internal microservices sharing a secret key (symmetric) - Your tokens contain standard OAuth scopes and claims **Methods:** -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -100,7 +103,7 @@ Validates the provided JWT bearer token. - AccessToken object if valid, None if invalid or expired -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -118,7 +121,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -### `StaticTokenVerifier` +### `StaticTokenVerifier` Simple static token verifier for testing and development. @@ -139,7 +142,7 @@ WARNING: Never use this in production - tokens are stored in plain text! **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx new file mode 100644 index 000000000..ba44efebe --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx @@ -0,0 +1,52 @@ +--- +title: redirect_validation +sidebarTitle: redirect_validation +--- + +# `fastmcp.server.auth.redirect_validation` + + +Utilities for validating client redirect URIs in OAuth flows. + +## Functions + +### `matches_allowed_pattern` + +```python +matches_allowed_pattern(uri: str, pattern: str) -> bool +``` + + +Check if a URI matches an allowed pattern with wildcard support. + +Patterns support * wildcard matching: +- http://localhost:* matches any localhost port +- http://127.0.0.1:* matches any 127.0.0.1 port +- https://*.example.com/* matches any subdomain of example.com +- https://app.example.com/auth/* matches any path under /auth/ + +**Args:** +- `uri`: The redirect URI to validate +- `pattern`: The allowed pattern (may contain wildcards) + +**Returns:** +- True if the URI matches the pattern + + +### `validate_redirect_uri` + +```python +validate_redirect_uri(redirect_uri: str | AnyUrl | None, allowed_patterns: list[str] | None) -> bool +``` + + +Validate a redirect URI against allowed patterns. + +**Args:** +- `redirect_uri`: The redirect URI to validate +- `allowed_patterns`: List of allowed patterns. If None, defaults to localhost. + If empty list, all URIs are allowed. + +**Returns:** +- True if the redirect URI is allowed + diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index 49915c942..8dbe81a99 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -7,7 +7,7 @@ sidebarTitle: context ## Functions -### `set_context` +### `set_context` ```python set_context(context: Context) -> Generator[Context, None, None] @@ -15,7 +15,7 @@ set_context(context: Context) -> Generator[Context, None, None] ## Classes -### `LogData` +### `LogData` Data object for passing log arguments to client-side handlers. @@ -24,7 +24,7 @@ This provides an interface to match the Python standard library logging, for compatibility with structured logging. -### `Context` +### `Context` Context object providing access to MCP capabilities. @@ -72,7 +72,7 @@ The context is optional - tools that don't need it can omit the parameter. **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -81,7 +81,7 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `request_context` +#### `request_context` ```python request_context(self) -> RequestContext[ServerSession, Any, Request] @@ -92,7 +92,7 @@ Access to the underlying request context. If called outside of a request context, this will raise a ValueError. -#### `report_progress` +#### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None @@ -105,7 +105,7 @@ Report progress for the current operation. - `total`: Optional total value e.g. 100 -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents] @@ -120,7 +120,7 @@ Read a resource by URI. - The resource content as either text or bytes -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -136,7 +136,7 @@ Send a log message to the client. - `extra`: Optional mapping for additional arguments -#### `client_id` +#### `client_id` ```python client_id(self) -> str | None @@ -145,7 +145,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -154,7 +154,7 @@ request_id(self) -> str Get the unique ID for this request. -#### `session_id` +#### `session_id` ```python session_id(self) -> str @@ -171,7 +171,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -180,7 +180,7 @@ session(self) -> ServerSession Access to the underlying session for advanced usage. -#### `debug` +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -189,7 +189,7 @@ debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, An Send a debug log message. -#### `info` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -198,7 +198,7 @@ info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any Send an info log message. -#### `warning` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -207,7 +207,7 @@ warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Send a warning log message. -#### `error` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -216,7 +216,7 @@ error(self, message: str, logger_name: str | None = None, extra: Mapping[str, An Send an error log message. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -225,7 +225,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_tool_list_changed` +#### `send_tool_list_changed` ```python send_tool_list_changed(self) -> None @@ -234,7 +234,7 @@ send_tool_list_changed(self) -> None Send a tool list changed notification to the client. -#### `send_resource_list_changed` +#### `send_resource_list_changed` ```python send_resource_list_changed(self) -> None @@ -243,7 +243,7 @@ send_resource_list_changed(self) -> None Send a resource list changed notification to the client. -#### `send_prompt_list_changed` +#### `send_prompt_list_changed` ```python send_prompt_list_changed(self) -> None @@ -252,7 +252,7 @@ send_prompt_list_changed(self) -> None Send a prompt list changed notification to the client. -#### `sample` +#### `sample` ```python sample(self, messages: str | list[str | SamplingMessage], system_prompt: str | None = None, include_context: IncludeContext | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> ContentBlock @@ -265,25 +265,25 @@ completion from the client. The client must be appropriately configured, or the request will error. -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation @@ -312,7 +312,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `get_http_request` +#### `get_http_request` ```python get_http_request(self) -> Request @@ -321,7 +321,7 @@ get_http_request(self) -> Request Get the active starlette request. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -330,7 +330,7 @@ set_state(self, key: str, value: Any) -> None Set a value in the context state. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any diff --git a/docs/python-sdk/fastmcp-server-proxy.mdx b/docs/python-sdk/fastmcp-server-proxy.mdx index db13c1378..3be5ddc97 100644 --- a/docs/python-sdk/fastmcp-server-proxy.mdx +++ b/docs/python-sdk/fastmcp-server-proxy.mdx @@ -268,7 +268,7 @@ Supports forwarding roots, sampling, elicitation, logging, and progress. **Methods:** -#### `default_sampling_handler` +#### `default_sampling_handler` ```python default_sampling_handler(cls, messages: list[mcp.types.SamplingMessage], params: mcp.types.CreateMessageRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> mcp.types.CreateMessageResult @@ -277,7 +277,7 @@ default_sampling_handler(cls, messages: list[mcp.types.SamplingMessage], params: A handler that forwards the sampling request from the remote server to the proxy's connected clients and relays the response back to the remote server. -#### `default_elicitation_handler` +#### `default_elicitation_handler` ```python default_elicitation_handler(cls, message: str, response_type: type, params: mcp.types.ElicitRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> ElicitResult @@ -286,7 +286,7 @@ default_elicitation_handler(cls, message: str, response_type: type, params: mcp. A handler that forwards the elicitation request from the remote server to the proxy's connected clients and relays the response back to the remote server. -#### `default_log_handler` +#### `default_log_handler` ```python default_log_handler(cls, message: LogMessage) -> None @@ -295,7 +295,7 @@ default_log_handler(cls, message: LogMessage) -> None A handler that forwards the log notification from the remote server to the proxy's connected clients. -#### `default_progress_handler` +#### `default_progress_handler` ```python default_progress_handler(cls, progress: float, total: float | None, message: str | None) -> None @@ -304,7 +304,7 @@ default_progress_handler(cls, progress: float, total: float | None, message: str A handler that forwards the progress notification from the remote server to the proxy's connected clients. -### `StatefulProxyClient` +### `StatefulProxyClient` A proxy client that provides a stateful client factory for the proxy server. @@ -318,7 +318,7 @@ Note that it is essential to ensure that the proxy server itself is also statefu **Methods:** -#### `clear` +#### `clear` ```python clear(self) @@ -327,7 +327,7 @@ clear(self) Clear all cached clients and force disconnect them. -#### `new_stateful` +#### `new_stateful` ```python new_stateful(self) -> Client[ClientTransportT] diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 4fcd592a6..372661303 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty context object -### `add_resource_prefix` +### `add_resource_prefix` ```python add_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str @@ -64,7 +64,7 @@ add_resource_prefix("resource:///absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `remove_resource_prefix` +### `remove_resource_prefix` ```python remove_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str @@ -103,7 +103,7 @@ remove_resource_prefix("resource://prefix//absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `has_resource_prefix` +### `has_resource_prefix` ```python has_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> bool @@ -143,35 +143,35 @@ False ## Classes -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` +#### `settings` ```python settings(self) -> Settings ``` -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `run_async` +#### `run_async` ```python run_async(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None @@ -183,7 +183,7 @@ Run the FastMCP server asynchronously. - `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http") -#### `run` +#### `run` ```python run(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None @@ -195,13 +195,13 @@ Run the FastMCP server. Note this is a synchronous function. - `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http") -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `get_tools` +#### `get_tools` ```python get_tools(self) -> dict[str, Tool] @@ -210,13 +210,13 @@ get_tools(self) -> dict[str, Tool] Get all registered tools, indexed by registered key. -#### `get_tool` +#### `get_tool` ```python get_tool(self, key: str) -> Tool ``` -#### `get_resources` +#### `get_resources` ```python get_resources(self) -> dict[str, Resource] @@ -225,13 +225,13 @@ get_resources(self) -> dict[str, Resource] Get all registered resources, indexed by registered key. -#### `get_resource` +#### `get_resource` ```python get_resource(self, key: str) -> Resource ``` -#### `get_resource_templates` +#### `get_resource_templates` ```python get_resource_templates(self) -> dict[str, ResourceTemplate] @@ -240,7 +240,7 @@ get_resource_templates(self) -> dict[str, ResourceTemplate] Get all registered resource templates, indexed by registered key. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, key: str) -> ResourceTemplate @@ -249,7 +249,7 @@ get_resource_template(self, key: str) -> ResourceTemplate Get a registered resource template by key. -#### `get_prompts` +#### `get_prompts` ```python get_prompts(self) -> dict[str, Prompt] @@ -258,13 +258,13 @@ get_prompts(self) -> dict[str, Prompt] List all available prompts. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, key: str) -> Prompt ``` -#### `custom_route` +#### `custom_route` ```python custom_route(self, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True) -> Callable[[Callable[[Request], Awaitable[Response]]], Callable[[Request], Awaitable[Response]]] @@ -285,7 +285,7 @@ Starlette's reverse URL lookup feature) - `include_in_schema`: Whether to include in OpenAPI schema, defaults to True -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool) -> Tool @@ -303,7 +303,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str) -> None @@ -318,7 +318,7 @@ Remove a tool from the server. - `NotFoundError`: If the tool is not found -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -327,7 +327,7 @@ add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfi Add a tool transformation. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, tool_name: str) -> None @@ -336,19 +336,19 @@ remove_tool_transformation(self, tool_name: str) -> None Remove a tool transformation. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool @@ -404,7 +404,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource) -> Resource @@ -419,7 +419,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -434,7 +434,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `add_resource_fn` +#### `add_resource_fn` ```python add_resource_fn(self, fn: AnyFunction, uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> None @@ -454,7 +454,7 @@ has parameters, it will be registered as a template resource. - `tags`: Optional set of tags for categorizing the resource -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate] @@ -514,7 +514,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt) -> Prompt @@ -529,19 +529,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt @@ -619,7 +619,7 @@ Decorator to register a prompt. ``` -#### `run_stdio_async` +#### `run_stdio_async` ```python run_stdio_async(self, show_banner: bool = True) -> None @@ -628,7 +628,7 @@ run_stdio_async(self, show_banner: bool = True) -> None Run the server using stdio transport. -#### `run_http_async` +#### `run_http_async` ```python run_http_async(self, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, stateless_http: bool | None = None) -> None @@ -647,7 +647,7 @@ Run the server using HTTP transport. - `stateless_http`: Whether to use stateless HTTP (defaults to settings.stateless_http) -#### `run_sse_async` +#### `run_sse_async` ```python run_sse_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None @@ -656,7 +656,7 @@ run_sse_async(self, host: str | None = None, port: int | None = None, log_level: Run the server using SSE transport. -#### `sse_app` +#### `sse_app` ```python sse_app(self, path: str | None = None, message_path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan @@ -670,7 +670,7 @@ Create a Starlette app for the SSE server. - `middleware`: A list of middleware to apply to the app -#### `streamable_http_app` +#### `streamable_http_app` ```python streamable_http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan @@ -683,7 +683,7 @@ Create a Starlette app for the StreamableHTTP server. - `middleware`: A list of middleware to apply to the app -#### `http_app` +#### `http_app` ```python http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http') -> StarletteWithLifespan @@ -700,13 +700,13 @@ Create a Starlette app using the specified HTTP transport. - A Starlette application configured with the specified transport -#### `run_streamable_http_async` +#### `run_streamable_http_async` ```python run_streamable_http_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], prefix: str | None = None, as_proxy: bool | None = None) -> None @@ -760,7 +760,7 @@ automatically determined based on whether the server has a custom lifespan - `prompt_separator`: Deprecated. Separator character for prompt names. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None, tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None) -> None @@ -801,7 +801,7 @@ applied using the protocol\://prefix/path format - `prompt_separator`: Deprecated. Separator for prompt names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -810,7 +810,7 @@ from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route Create a FastMCP server from an OpenAPI specification. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -819,7 +819,7 @@ from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] Create a FastMCP server from a FastAPI application. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -833,7 +833,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `from_client` +#### `from_client` ```python from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPProxy @@ -842,4 +842,10 @@ from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPPr Create a FastMCP proxy server from a FastMCP client. -### `MountedServer` +#### `generate_name` + +```python +generate_name(cls, name: str | None = None) -> str +``` + +### `MountedServer` diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx index 6c460937b..185e05ab3 100644 --- a/docs/python-sdk/fastmcp-settings.mdx +++ b/docs/python-sdk/fastmcp-settings.mdx @@ -71,7 +71,7 @@ This property is for backwards compatibility with FastMCP < 2.8.0, which accessed fastmcp.settings.settings -#### `normalize_log_level` +#### `normalize_log_level` ```python normalize_log_level(cls, v) diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index 5cfed788b..fe16ae521 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -260,7 +260,7 @@ Tool.from_tool(parent, output_schema={ }) # Disable structured outputs -Tool.from_tool(parent, output_schema=False) +Tool.from_tool(parent, output_schema=None) # Return ToolResult for full control async def custom_output(**kwargs) -> ToolResult: diff --git a/docs/python-sdk/fastmcp-utilities-cli.mdx b/docs/python-sdk/fastmcp-utilities-cli.mdx index c6c159042..6d49ca6e0 100644 --- a/docs/python-sdk/fastmcp-utilities-cli.mdx +++ b/docs/python-sdk/fastmcp-utilities-cli.mdx @@ -7,7 +7,7 @@ sidebarTitle: cli ## Functions -### `log_server_banner` +### `log_server_banner` ```python log_server_banner(server: FastMCP[Any], transport: Literal['stdio', 'http', 'sse', 'streamable-http']) -> None diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config.mdx b/docs/python-sdk/fastmcp-utilities-fastmcp_config.mdx new file mode 100644 index 000000000..1377f6eeb --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-fastmcp_config.mdx @@ -0,0 +1,13 @@ +--- +title: fastmcp_config +sidebarTitle: fastmcp_config +--- + +# `fastmcp.utilities.fastmcp_config` + + +FastMCP Configuration module. + +This module provides versioned configuration support for FastMCP servers. +The current version is v1, which is re-exported here for convenience. + diff --git a/docs/python-sdk/fastmcp-utilities-mcp_config.mdx b/docs/python-sdk/fastmcp-utilities-mcp_config.mdx index b65ff91a7..403acd2be 100644 --- a/docs/python-sdk/fastmcp-utilities-mcp_config.mdx +++ b/docs/python-sdk/fastmcp-utilities-mcp_config.mdx @@ -7,7 +7,7 @@ sidebarTitle: mcp_config ## Functions -### `mcp_config_to_servers_and_transports` +### `mcp_config_to_servers_and_transports` ```python mcp_config_to_servers_and_transports(config: MCPConfig) -> list[tuple[str, FastMCP[Any], ClientTransport]] @@ -17,7 +17,7 @@ mcp_config_to_servers_and_transports(config: MCPConfig) -> list[tuple[str, FastM A utility function to convert each entry of an MCP Config into a transport and server. -### `mcp_server_type_to_servers_and_transports` +### `mcp_server_type_to_servers_and_transports` ```python mcp_server_type_to_servers_and_transports(name: str, mcp_server: MCPServerTypes) -> tuple[str, FastMCP[Any], ClientTransport] diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx index adb5df0c2..29a9cd2df 100644 --- a/docs/python-sdk/fastmcp-utilities-types.mdx +++ b/docs/python-sdk/fastmcp-utilities-types.mdx @@ -10,7 +10,7 @@ Common types used across FastMCP. ## Functions -### `get_cached_typeadapter` +### `get_cached_typeadapter` ```python get_cached_typeadapter(cls: T) -> TypeAdapter[T] @@ -23,7 +23,7 @@ However, this isn't feasible for user-generated functions. Instead, we use a cache to minimize the cost of creating them as much as possible. -### `issubclass_safe` +### `issubclass_safe` ```python issubclass_safe(cls: type, base: type) -> bool @@ -33,10 +33,10 @@ issubclass_safe(cls: type, base: type) -> bool Check if cls is a subclass of base, even if cls is a type variable. -### `is_class_member_of_type` +### `is_class_member_of_type` ```python -is_class_member_of_type(cls: type, base: type) -> bool +is_class_member_of_type(cls: Any, base: type) -> bool ``` @@ -46,7 +46,7 @@ Base can be a type, a UnionType, or an Annotated type. Generic types are not considered members (e.g. T is not a member of list\[T]). -### `find_kwarg_by_type` +### `find_kwarg_by_type` ```python find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None @@ -58,7 +58,7 @@ Find the name of the kwarg that is of type kwarg_type. Includes union types that contain the kwarg_type, as well as Annotated types. -### `replace_type` +### `replace_type` ```python replace_type(type_, type_map: dict[type, type]) @@ -87,13 +87,13 @@ list[list[str]] ## Classes -### `FastMCPBaseModel` +### `FastMCPBaseModel` Base model for FastMCP models. -### `Image` +### `Image` Helper class for returning images from tools. @@ -101,7 +101,7 @@ Helper class for returning images from tools. **Methods:** -#### `to_image_content` +#### `to_image_content` ```python to_image_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.ImageContent @@ -110,7 +110,7 @@ to_image_content(self, mime_type: str | None = None, annotations: Annotations | Convert to MCP ImageContent. -### `Audio` +### `Audio` Helper class for returning audio from tools. @@ -118,13 +118,13 @@ Helper class for returning audio from tools. **Methods:** -#### `to_audio_content` +#### `to_audio_content` ```python to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.AudioContent ``` -### `File` +### `File` Helper class for returning audio from tools. @@ -132,8 +132,10 @@ Helper class for returning audio from tools. **Methods:** -#### `to_resource_content` +#### `to_resource_content` ```python to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.EmbeddedResource ``` + +### `ContextSamplingFallbackProtocol` diff --git a/docs/schemas/fastmcp_config/latest.json b/docs/schemas/fastmcp_config/latest.json deleted file mode 120000 index 2cf0f1ec9..000000000 --- a/docs/schemas/fastmcp_config/latest.json +++ /dev/null @@ -1 +0,0 @@ -v1.json \ No newline at end of file diff --git a/docs/schemas/fastmcp_config/v1.json b/docs/schemas/fastmcp_config/v1.json deleted file mode 120000 index 374e96936..000000000 --- a/docs/schemas/fastmcp_config/v1.json +++ /dev/null @@ -1 +0,0 @@ -../../../src/fastmcp/utilities/fastmcp_config/v1/schema.json \ No newline at end of file diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index dec952cb1..7f566ff68 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -161,6 +161,15 @@ The `OAuthProxy` class provides the complete proxy implementation: Resource server URL (defaults to base_url) + + + List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`). + - `None` (default): Only localhost redirect URIs allowed (`http://localhost:*`, `http://127.0.0.1:*`) + - Empty list `[]`: All redirect URIs allowed (not recommended for production) + - Custom list: Only matching patterns allowed + + These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI. + ```python @@ -219,6 +228,32 @@ The proxy automatically: - Validates tokens using your provider's public keys or API - Maintains PKCE security throughout the flow +## Client Redirect URI Security + + +By default, OAuth Proxy only accepts localhost redirect URIs from MCP clients for security. You can customize this with the `allowed_client_redirect_uris` parameter: + +```python +# Default: localhost only (secure) +auth = OAuthProxy(...) + +# Custom patterns with wildcards +auth = OAuthProxy( + ..., + allowed_client_redirect_uris=[ + "http://localhost:*", + "https://app.example.com/auth/*" + ] +) + +# Allow all (NOT recommended for production) +auth = OAuthProxy( + ..., + allowed_client_redirect_uris=[] +) +``` + + ## Client Compatibility diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx index b230346eb..5f6a485cd 100644 --- a/docs/servers/auth/remote-oauth.mdx +++ b/docs/servers/auth/remote-oauth.mdx @@ -111,7 +111,9 @@ token_verifier = JWTVerifier( auth = RemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")], - resource_server_url="https://api.yourcompany.com" + resource_server_url="https://api.yourcompany.com", + # Optional: customize allowed client redirect URIs (defaults to localhost only) + allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"] ) mcp = FastMCP(name="Company API", auth=auth) @@ -192,6 +194,18 @@ WorkOS's support for Dynamic Client Registration makes it particularly well-suit → **Complete WorkOS tutorial**: [AuthKit Integration Guide](/integrations/authkit) +## Client Redirect URI Security + + +`RemoteAuthProvider` also supports the `allowed_client_redirect_uris` parameter for controlling which redirect URIs are accepted from MCP clients during DCR: + +- `None` (default): Only localhost patterns allowed +- Custom list: Specify allowed patterns with wildcard support +- Empty list `[]`: Allow all (not recommended) + +This provides defense-in-depth even though DCR providers typically validate redirect URIs themselves. + + ## Implementation Considerations Remote OAuth integration requires careful attention to several technical details that affect reliability and security. diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx index 2c68f866d..6d6dd4903 100644 --- a/docs/servers/auth/token-verification.mdx +++ b/docs/servers/auth/token-verification.mdx @@ -80,9 +80,48 @@ This configuration creates a server that validates JWTs issued by `auth.yourcomp The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server. +#### Symmetric Key Verification (HMAC) + +Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators. + +This approach is commonly used in microservices architectures where services share a secret key, or when your authentication service and MCP server are both managed by the same organization. The HMAC algorithms (HS256, HS384, HS512) provide strong security when the shared secret is properly managed. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth.providers.jwt import JWTVerifier + +# Use a shared secret for symmetric key verification +verifier = JWTVerifier( + public_key="your-shared-secret-key-minimum-32-chars", # Despite the name, this accepts symmetric secrets + issuer="internal-auth-service", + audience="mcp-internal-api", + algorithm="HS256" # or HS384, HS512 for stronger security +) + +mcp = FastMCP(name="Internal API", auth=verifier) +``` + +The verifier will validate tokens signed with the same secret using the specified HMAC algorithm. This approach offers several advantages for internal systems: + +- **Simplicity**: No key pair management or certificate distribution +- **Performance**: HMAC operations are typically faster than RSA +- **Compatibility**: Works well with existing microservice authentication patterns + + +The parameter is named `public_key` for backwards compatibility, but when using HMAC algorithms (HS256/384/512), it accepts the symmetric secret string. + + + +**Security Considerations for Symmetric Keys:** +- Use a strong, randomly generated secret (minimum 32 characters recommended) +- Never expose the secret in logs, error messages, or version control +- Implement secure key distribution and rotation mechanisms +- Consider using asymmetric keys (RSA/ECDSA) for external-facing APIs + + #### Static Public Key Verification -Static public key verification works when you have a fixed signing key and don't need automatic key rotation. This approach simplifies deployment in environments where JWKS endpoints aren't available. +Static public key verification works when you have a fixed RSA or ECDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available. ```python from fastmcp import FastMCP @@ -102,7 +141,7 @@ verifier = JWTVerifier( mcp = FastMCP(name="Protected API", auth=verifier) ``` -This configuration validates tokens using a specific public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach works well for controlled environments or when using dedicated signing keys. +This configuration validates tokens using a specific RSA or ECDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys. ### Development and Testing @@ -180,11 +219,17 @@ Environment-based configuration separates authentication settings from applicati # Enable JWT verification export FASTMCP_SERVER_AUTH=JWT -# Configure JWT verification parameters +# For asymmetric verification with JWKS endpoint: export FASTMCP_SERVER_AUTH_JWT_JWKS_URI="https://auth.company.com/.well-known/jwks.json" export FASTMCP_SERVER_AUTH_JWT_ISSUER="https://auth.company.com" export FASTMCP_SERVER_AUTH_JWT_AUDIENCE="mcp-production-api" export FASTMCP_SERVER_AUTH_JWT_REQUIRED_SCOPES="read:data,write:data" + +# OR for symmetric key verification (HMAC): +export FASTMCP_SERVER_AUTH_JWT_PUBLIC_KEY="your-shared-secret-key-minimum-32-chars" +export FASTMCP_SERVER_AUTH_JWT_ALGORITHM="HS256" # or HS384, HS512 +export FASTMCP_SERVER_AUTH_JWT_ISSUER="internal-auth-service" +export FASTMCP_SERVER_AUTH_JWT_AUDIENCE="mcp-internal-api" ``` With these environment variables configured, your FastMCP server automatically enables JWT verification: diff --git a/examples/atproto_mcp/fastmcp.json b/examples/atproto_mcp/fastmcp.json index 96b93bd1f..c75f8e091 100644 --- a/examples/atproto_mcp/fastmcp.json +++ b/examples/atproto_mcp/fastmcp.json @@ -1,9 +1,11 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": "src/atproto_mcp/server.py", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "src/atproto_mcp/server.py" + }, "environment": { "dependencies": [ "atproto_mcp@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/atproto_mcp" ] } -} \ No newline at end of file +} diff --git a/examples/fastmcp_config/env_interpolation_example.json b/examples/fastmcp_config/env_interpolation_example.json index 9b49c831b..0eb4af048 100644 --- a/examples/fastmcp_config/env_interpolation_example.json +++ b/examples/fastmcp_config/env_interpolation_example.json @@ -1,5 +1,5 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "entrypoint": "src/server.py:app", "environment": { "python": "3.12", @@ -17,4 +17,4 @@ "FEATURE_FLAGS": "${FEATURE_FLAGS}" } } -} \ No newline at end of file +} diff --git a/examples/fastmcp_config/fastmcp.json b/examples/fastmcp_config/fastmcp.json index 5630c0079..3511d1153 100644 --- a/examples/fastmcp_config/fastmcp.json +++ b/examples/fastmcp_config/fastmcp.json @@ -1,6 +1,8 @@ { "$schema": "https://gofastmcp.com/schemas/fastmcp/v1.json", - "entrypoint": "server.py", + "source": { + "path": "server.py" + }, "environment": { "python": "3.12", "dependencies": ["requests"] diff --git a/examples/fastmcp_config/full_example.fastmcp.json b/examples/fastmcp_config/full_example.fastmcp.json index 9212903b7..e31a4f8cb 100644 --- a/examples/fastmcp_config/full_example.fastmcp.json +++ b/examples/fastmcp_config/full_example.fastmcp.json @@ -1,15 +1,12 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "python": "3.12", - "dependencies": [ - "requests>=2.31.0", - "httpx" - ], + "dependencies": ["requests>=2.31.0", "httpx"], "requirements": null, "project": null, "editable": null @@ -27,4 +24,4 @@ "cwd": null, "args": null } -} \ No newline at end of file +} diff --git a/examples/fastmcp_config_demo/fastmcp.json b/examples/fastmcp_config_demo/fastmcp.json index 022216c43..9027c6c81 100644 --- a/examples/fastmcp_config_demo/fastmcp.json +++ b/examples/fastmcp_config_demo/fastmcp.json @@ -1,15 +1,14 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": "server.py", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py" + }, "environment": { "python": "3.11", - "dependencies": [ - "pyautogui", - "Pillow" - ] + "dependencies": ["pyautogui", "Pillow"] }, "deployment": { "transport": "stdio", "log_level": "INFO" } -} \ No newline at end of file +} diff --git a/examples/memory.fastmcp.json b/examples/memory.fastmcp.json index d92f97872..5ee1aea97 100644 --- a/examples/memory.fastmcp.json +++ b/examples/memory.fastmcp.json @@ -1,12 +1,7 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "entrypoint": "memory.py", "environment": { - "dependencies": [ - "pydantic-ai-slim[openai]", - "asyncpg", - "numpy", - "pgvector" - ] + "dependencies": ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector"] } -} \ No newline at end of file +} diff --git a/examples/mount_example.fastmcp.json b/examples/mount_example.fastmcp.json index 7ad9e3c00..d30d2e3e9 100644 --- a/examples/mount_example.fastmcp.json +++ b/examples/mount_example.fastmcp.json @@ -1,4 +1,4 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "entrypoint": "mount_example.py" -} \ No newline at end of file +} diff --git a/examples/screenshot.fastmcp.json b/examples/screenshot.fastmcp.json index ef35e6fdd..9ff06d797 100644 --- a/examples/screenshot.fastmcp.json +++ b/examples/screenshot.fastmcp.json @@ -1,7 +1,7 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "entrypoint": "screenshot.py", "environment": { "dependencies": ["pyautogui", "Pillow"] } -} \ No newline at end of file +} diff --git a/examples/smart_home/hub.fastmcp.json b/examples/smart_home/hub.fastmcp.json index 175c8961c..3e84fc8c6 100644 --- a/examples/smart_home/hub.fastmcp.json +++ b/examples/smart_home/hub.fastmcp.json @@ -1,9 +1,9 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "entrypoint": "src/smart_home/hub.py", "environment": { "dependencies": [ "smart_home@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/smart_home" ] } -} \ No newline at end of file +} diff --git a/examples/smart_home/lights.fastmcp.json b/examples/smart_home/lights.fastmcp.json index 27bba2a06..23b0b3176 100644 --- a/examples/smart_home/lights.fastmcp.json +++ b/examples/smart_home/lights.fastmcp.json @@ -1,9 +1,9 @@ { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "entrypoint": "src/smart_home/lights/server.py", "environment": { "dependencies": [ "smart_home@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/smart_home" ] } -} \ No newline at end of file +} diff --git a/pyproject.toml b/pyproject.toml index f86613372..c5c8cfa6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dev = [ "copychat>=0.5.2", "dirty-equals>=0.9.0", "fastapi>=0.115.12", + "inline-snapshot[dirty-equals]>=0.27.2", "ipython>=8.12.3", "pdbpp>=0.10.3", "pre-commit", @@ -124,7 +125,6 @@ python-version = "3.10" [tool.ty.rules] # Rules with too many errors to fix right now (40+ each) -invalid-argument-type = "ignore" # 40 errors no-matching-overload = "ignore" # 126 errors unknown-argument = "ignore" # 61 errors unresolved-attribute = "ignore" # 60 errors diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index a3c93e072..3a596584a 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -6,10 +6,11 @@ from fastmcp.settings import Settings from fastmcp.utilities.logging import configure_logging as _configure_logging settings = Settings() -_configure_logging( - level=settings.log_level, - enable_rich_tracebacks=settings.enable_rich_tracebacks, -) +if settings.log_enabled: + _configure_logging( + level=settings.log_level, + enable_rich_tracebacks=settings.enable_rich_tracebacks, + ) from fastmcp.server.server import FastMCP from fastmcp.server.context import Context diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index 33d635b9c..424469d77 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -6,6 +6,7 @@ import sys from pathlib import Path from typing import Any +from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -89,20 +90,20 @@ def update_claude_config( else: env_vars = existing_env - # Build uv run command - args = ["run"] - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None if with_packages: - packages.update(pkg for pkg in with_packages if pkg) + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) + # Build uv run command using Environment.build_uv_args() + env_config = Environment( + dependencies=deduplicated_packages, + editable=str(with_editable) if with_editable else None, + ) + args = env_config.build_uv_args() # Convert file path to absolute before adding to command # Split off any :object suffix first diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 893762edd..0ad3016ad 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -2,16 +2,18 @@ import importlib.metadata import importlib.util +import json import os import platform import subprocess import sys +from contextlib import contextmanager from pathlib import Path from typing import Annotated, Literal import cyclopts import pyperclip -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from rich.console import Console from rich.table import Table @@ -21,6 +23,7 @@ from fastmcp.cli.install import install_app from fastmcp.server.server import FastMCP from fastmcp.utilities.inspect import FastMCPInfo, inspect_fastmcp from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger("cli") console = Console() @@ -57,46 +60,27 @@ def _parse_env_var(env_var: str) -> tuple[str, str]: return key.strip(), value.strip() -def _build_uv_command( - server_spec: str, - with_editable: Path | None = None, - with_packages: list[str] | None = None, - no_banner: bool = False, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, -) -> list[str]: - """Build the uv run command that runs a MCP server through mcp run.""" - cmd = ["uv", "run"] +@contextmanager +def with_argv(args: list[str] | None): + """Temporarily replace sys.argv if args provided. - # Add Python version if specified - if python_version: - cmd.extend(["--python", python_version]) + This context manager is used at the CLI boundary to inject + server arguments when needed, without mutating sys.argv deep + in the source loading logic. - # Add project if specified - if project: - cmd.extend(["--project", str(project)]) - - cmd.extend(["--with", "fastmcp"]) - - if with_editable: - cmd.extend(["--with-editable", str(with_editable)]) - - if with_packages: - for pkg in with_packages: - if pkg: - cmd.extend(["--with", pkg]) - - if with_requirements: - cmd.extend(["--with-requirements", str(with_requirements)]) - - # Add mcp run command - cmd.extend(["fastmcp", "run", server_spec]) - - if no_banner: - cmd.append("--no-banner") - - return cmd + Args are provided without the script name, so we preserve sys.argv[0] + and replace the rest. + """ + if args is not None: + original = sys.argv[:] + try: + # Preserve the script name (sys.argv[0]) and replace the rest + sys.argv = [sys.argv[0]] + args + yield + finally: + sys.argv = original + else: + yield @app.command @@ -107,7 +91,7 @@ def version( cyclopts.Parameter( "--copy", help="Copy version information to clipboard", - negative=False, + negative="", ), ] = False, ): @@ -153,7 +137,7 @@ async def dev( cyclopts.Parameter( "--with", help="Additional packages to install", - negative=False, + negative="", ), ] = [], inspector_version: Annotated[ @@ -204,12 +188,16 @@ async def dev( Args: server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json """ + from pathlib import Path + + from fastmcp.utilities.fastmcp_config import FastMCPConfig + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource + + config = None + config_path = None + # Auto-detect fastmcp.json if no server_spec provided if server_spec is None: - from pathlib import Path - - from fastmcp.utilities.fastmcp_config import FastMCPConfig - config_path = Path("fastmcp.json") if not config_path.exists(): # Check if fastmcp.json exists in current directory @@ -222,44 +210,50 @@ async def dev( "Please specify a server file or create a fastmcp.json configuration." ) sys.exit(1) + server_spec = str(config_path) + logger.info(f"Using configuration from {config_path}") - # Load the config to get settings - config = FastMCPConfig.from_file(config_path) - entrypoint = config.get_entrypoint(config_path) - - # Convert entrypoint to string format for dev command - if entrypoint.object: - server_spec = f"{entrypoint.file}:{entrypoint.object}" - else: - server_spec = entrypoint.file + # Create FastMCPConfig from server_spec + if server_spec.endswith(".json"): + # Load existing config + config = FastMCPConfig.from_file(Path(server_spec)) # Merge environment settings with CLI args (CLI takes precedence) if config.environment: - merged_env = config.environment.merge_with_cli_args( - python=python, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - with_editable=with_editable, + python = python or config.environment.python + project = project or ( + Path(config.environment.project) if config.environment.project else None ) - python = merged_env["python"] - with_packages = merged_env["with_packages"] - with_requirements = merged_env["with_requirements"] - project = merged_env["project"] - with_editable = merged_env["with_editable"] + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) + with_editable = with_editable or ( + Path(config.environment.editable) + if config.environment.editable + else None + ) + + # Merge packages from both sources + if config.environment.dependencies: + packages = list(config.environment.dependencies) + if with_packages: + packages.extend(with_packages) + with_packages = packages # Get server port from deployment config if not specified if config.deployment and config.deployment.port: server_port = server_port or config.deployment.port - - logger.info(f"Using configuration from {config_path}") - file, server_object = run_module.parse_file_path(server_spec) + else: + # Create config from file path + source = FileSystemSource(path=server_spec) + config = FastMCPConfig(source=source) logger.debug( "Starting dev server", extra={ - "file": str(file), - "server_object": server_object, + "server_spec": server_spec, "with_editable": str(with_editable) if with_editable else None, "with_packages": with_packages, "ui_port": ui_port, @@ -268,9 +262,8 @@ async def dev( ) try: - # Import server to get dependencies - # TODO: Remove dependencies handling (deprecated in v2.11.4) - server: FastMCP = await run_module.import_server(file, server_object) + # Load server to check for deprecated dependencies + server: FastMCP = await config.source.load_server() if server.dependencies: import warnings @@ -302,15 +295,20 @@ async def dev( if inspector_version: inspector_cmd += f"@{inspector_version}" - uv_cmd = _build_uv_command( - server_spec, - with_editable, - with_packages, - no_banner=True, - python_version=python, - with_requirements=with_requirements, - project=project, + # Create Environment object from CLI args + from fastmcp.utilities.fastmcp_config import Environment + + env_config = Environment( + python=python, + dependencies=with_packages if with_packages else None, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + editable=str(with_editable) if with_editable else None, ) + uv_cmd = ["uv"] + env_config.build_uv_args(["fastmcp", "run", server_spec]) + + # Add --no-banner flag for dev command + uv_cmd.append("--no-banner") # Run the MCP Inspector command with shell=True on Windows shell = sys.platform == "win32" @@ -325,7 +323,7 @@ async def dev( logger.error( "Dev server failed", extra={ - "file": str(file), + "file": str(server_spec), "error": str(e), "returncode": e.returncode, }, @@ -336,7 +334,7 @@ async def dev( "npx not found. Please ensure Node.js and npm are properly installed " "and added to your system PATH. You may need to restart your terminal " "after installation.", - extra={"file": str(file)}, + extra={"file": str(server_spec)}, ) sys.exit(1) @@ -385,7 +383,7 @@ async def run( cyclopts.Parameter( "--no-banner", help="Don't show the server banner", - negative=False, + negative="", ), ] = False, python: Annotated[ @@ -400,7 +398,7 @@ async def run( cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", - negative=False, + negative="", ), ] = [], project: Annotated[ @@ -417,6 +415,22 @@ async def run( help="Requirements file to install dependencies from", ), ] = None, + skip_env: Annotated[ + bool, + cyclopts.Parameter( + "--skip-env", + help="Skip environment setup with uv (use when already in a uv environment)", + negative="", + ), + ] = False, + skip_source: Annotated[ + bool, + cyclopts.Parameter( + "--skip-source", + help="Skip source preparation step (use when source is already prepared)", + negative="", + ), + ] = False, ) -> None: """Run an MCP server or connect to a remote one. @@ -441,6 +455,7 @@ async def run( config = None config_path = None + editable = None # Initialize editable variable # Auto-detect fastmcp.json if no server_spec provided if server_spec is None: @@ -460,41 +475,69 @@ async def run( server_spec = str(config_path) logger.info(f"Using configuration from {config_path}") - # Load config if server_spec is a fastmcp.json file - if server_spec.endswith("fastmcp.json"): + # Load config if server_spec is a .json file + if server_spec.endswith(".json"): config_path = Path(server_spec) if config_path.exists(): - config = FastMCPConfig.from_file(config_path) + # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig + try: + with open(config_path) as f: + data = json.load(f) - # Merge deployment config with CLI values (CLI takes precedence) - if config.deployment: - merged_deploy = config.deployment.merge_with_cli_args( - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - server_args=list(server_args) if server_args else None, - ) - transport = merged_deploy["transport"] - host = merged_deploy["host"] - port = merged_deploy["port"] - path = merged_deploy["path"] - log_level = merged_deploy["log_level"] - server_args = merged_deploy["server_args"] or () + # Check if it's an MCPConfig first (has canonical mcpServers key) + if "mcpServers" in data: + # It's an MCPConfig, we don't process these in the run command + # They should be handled through different code paths + config = None + else: + # Try to parse as FastMCPConfig + try: + adapter = get_cached_typeadapter(FastMCPConfig) + config = adapter.validate_python(data) - # Merge environment config with CLI values (CLI takes precedence) - if config.environment: - merged_env = config.environment.merge_with_cli_args( - python=python, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - ) - python = merged_env["python"] - with_packages = merged_env["with_packages"] - with_requirements = merged_env["with_requirements"] - project = merged_env["project"] + # Merge deployment config with CLI values (CLI takes precedence) + if config.deployment: + transport = transport or config.deployment.transport + host = host or config.deployment.host + port = port or config.deployment.port + path = path or config.deployment.path + log_level = log_level or config.deployment.log_level + server_args = ( + tuple(server_args) + if server_args + else tuple(config.deployment.args or ()) + ) + + # Merge environment config with CLI values (CLI takes precedence) + if config.environment: + python = python or config.environment.python + project = project or ( + Path(config.environment.project) + if config.environment.project + else None + ) + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) + # Extract editable from config (no CLI override for this) + editable = config.environment.editable + + # Merge packages from both sources + if config.environment.dependencies: + packages = list(config.environment.dependencies) + if with_packages: + packages.extend(with_packages) + with_packages = packages + except ValidationError: + # Not a valid FastMCPConfig, treat as regular server spec + config = None + except (json.JSONDecodeError, FileNotFoundError): + # Not a valid JSON file, treat as regular server spec + config = None + else: + config = None logger.debug( "Running server or client", extra={ @@ -509,8 +552,11 @@ async def run( ) # Check if we need to use uv run (either from CLI args or config) - needs_uv = python or with_packages or with_requirements or project - if not needs_uv and config and config.environment: + # Skip if --skip-env flag is set (we're already in a uv environment) + needs_uv = not skip_env and ( + python or with_packages or with_requirements or project or editable + ) + if not skip_env and not needs_uv and config and config.environment: # Check if config's environment needs uv needs_uv = config.environment.needs_uv() @@ -529,6 +575,7 @@ async def run( path=path, log_level=log_level, show_banner=not no_banner, + editable=editable, ) except Exception as e: logger.error( @@ -551,6 +598,7 @@ async def run( log_level=log_level, server_args=list(server_args), show_banner=not no_banner, + skip_source=skip_source, ) except Exception as e: logger.error( @@ -586,7 +634,7 @@ async def inspect( cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", - negative=False, + negative="", ), ] = [], project: Annotated[ @@ -621,10 +669,10 @@ async def inspect( Args: server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json """ - # Load configuration if needed from pathlib import Path from fastmcp.utilities.fastmcp_config import FastMCPConfig + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource config = None config_path = None @@ -647,31 +695,53 @@ async def inspect( server_spec = str(config_path) logger.info(f"Using configuration from {config_path}") - # Load config if server_spec is a fastmcp.json file - if server_spec.endswith("fastmcp.json"): + # Create FastMCPConfig from server_spec + if server_spec.endswith(".json"): config_path = Path(server_spec) if config_path.exists(): - config = FastMCPConfig.from_file(config_path) - # Get the actual entrypoint with resolved paths - entrypoint = config.get_entrypoint(config_path) + try: + with open(config_path) as f: + data = json.load(f) - if entrypoint.object: - server_spec = f"{entrypoint.file}:{entrypoint.object}" - else: - server_spec = entrypoint.file + # Check if it's an MCPConfig (has mcpServers key) + if "mcpServers" in data: + # MCPConfig - we don't process these in inspect + logger.error("MCPConfig files are not supported by inspect command") + sys.exit(1) + else: + # It's a FastMCPConfig + config = FastMCPConfig.from_file(config_path) - # Merge environment settings from config with CLI (CLI takes precedence) - if config.environment: - merged_env = config.environment.merge_with_cli_args( - python=python, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - ) - python = merged_env["python"] - with_packages = merged_env["with_packages"] - with_requirements = merged_env["with_requirements"] - project = merged_env["project"] + # Merge environment settings from config with CLI (CLI takes precedence) + if config.environment: + python = python or config.environment.python + project = project or ( + Path(config.environment.project) + if config.environment.project + else None + ) + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) + + # Merge packages from both sources + if config.environment.dependencies: + packages = list(config.environment.dependencies) + if with_packages: + packages.extend(with_packages) + with_packages = packages + except (json.JSONDecodeError, ValidationError) as e: + logger.error(f"Invalid configuration file: {e}") + sys.exit(1) + else: + logger.error(f"Configuration file not found: {config_path}") + sys.exit(1) + else: + # Create config from file path + source = FileSystemSource(path=server_spec) + config = FastMCPConfig(source=source) # Check if we need to use uv run needs_uv = python or with_packages or with_requirements or project @@ -680,54 +750,37 @@ async def inspect( if needs_uv: # Build and run uv command - if config and config.environment: - # Use environment config's run_with_uv method - inspect_command = [ - "fastmcp", - "inspect", - server_spec, - "--output", - str(output), - ] - config.environment.run_with_uv(inspect_command) - else: - # Build an EnvironmentConfig from CLI args for consistency - from fastmcp.utilities.fastmcp_config import ( - EnvironmentConfig, - ) + from fastmcp.utilities.fastmcp_config import Environment - env_config = EnvironmentConfig( - python=python, - dependencies=with_packages, - requirements=str(with_requirements) if with_requirements else None, - project=str(project) if project else None, - ) + # Create or update environment config + env_config = Environment( + python=python, + dependencies=with_packages if with_packages else None, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + ) - inspect_command = [ - "fastmcp", - "inspect", - server_spec, - "--output", - str(output), - ] - env_config.run_with_uv(inspect_command) - - # Direct import path (no uv needed) - # Parse the server specification - file, server_object = run_module.parse_file_path(server_spec) + inspect_command = [ + "fastmcp", + "inspect", + server_spec, + "--output", + str(output), + ] + env_config.run_with_uv(inspect_command) + return # run_with_uv exits the process logger.debug( "Inspecting server", extra={ - "file": str(file), - "server_object": server_object, + "server_spec": server_spec, "output": str(output), }, ) try: - # Import the server - server = await run_module.import_server(file, server_object) + # Load the server using the config + server = await config.source.load_server() # Get server information - using native async support info = await inspect_fastmcp(server) @@ -765,43 +818,6 @@ async def inspect( sys.exit(1) -@app.command -def generate_schema( - *, - output: Annotated[ - Path | None, - cyclopts.Parameter( - name=["--output", "-o"], - help="Output file path for the JSON schema", - ), - ] = None, -) -> None: - """Generate JSON schema for fastmcp.json configuration files. - - This generates a JSON schema that can be used by IDEs and validators - to provide auto-completion and validation for fastmcp.json files. - - Examples: - fastmcp generate-schema - fastmcp generate-schema -o schema.json - """ - import json - - from fastmcp.utilities.fastmcp_config import ( - generate_schema as gen_schema, - ) - - schema = gen_schema() - schema_json = json.dumps(schema, indent=2) - - if output: - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(schema_json) - logger.info(f"Schema written to {output}") - else: - console.print(schema_json) - - # Add install subcommands using proper Cyclopts pattern app.command(install_app) diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index 490de9545..b51b6a8a0 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -9,6 +9,7 @@ from typing import Annotated import cyclopts from rich import print +from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -106,31 +107,23 @@ def install_claude_code( ) return False - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None if with_packages: - packages.update(pkg for pkg in with_packages if pkg) + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + # Build uv run command using Environment.build_uv_args() + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + editable=str(with_editable) if with_editable else None, + ) + args = env_config.build_uv_args() # Build server spec from parsed components if server_object: @@ -189,7 +182,7 @@ async def claude_code_command( cyclopts.Parameter( "--with", help="Additional packages to install", - negative=False, + negative="", ), ] = [], env_vars: Annotated[ @@ -197,7 +190,7 @@ async def claude_code_command( cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format", - negative=False, + negative="", ), ] = [], env_file: Annotated[ diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index 56f5d2bc8..542698992 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -9,6 +9,7 @@ import cyclopts from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file +from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -72,31 +73,22 @@ def install_claude_desktop( config_file = config_dir / "claude_desktop_config.json" - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None if with_packages: - packages.update(pkg for pkg in with_packages if pkg) + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + editable=str(with_editable) if with_editable else None, + ) + args = env_config.build_uv_args() # Build server spec from parsed components if server_object: @@ -162,7 +154,7 @@ async def claude_desktop_command( cyclopts.Parameter( "--with", help="Additional packages to install", - negative=False, + negative="", ), ] = [], env_vars: Annotated[ @@ -170,7 +162,7 @@ async def claude_desktop_command( cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format", - negative=False, + negative="", ), ] = [], env_file: Annotated[ diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index fd252bf23..92d10d495 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -10,6 +10,7 @@ import cyclopts from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file +from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -106,31 +107,22 @@ def install_cursor_workspace( config_file = cursor_dir / "mcp.json" - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None if with_packages: - packages.update(pkg for pkg in with_packages if pkg) + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements.resolve()) if with_requirements else None, + project=str(project.resolve()) if project else None, + editable=str(with_editable.resolve()) if with_editable else None, + ) + args = env_config.build_uv_args() # Build server spec from parsed components if server_object: @@ -194,31 +186,23 @@ def install_cursor( Returns: True if installation was successful, False otherwise """ - # Build uv run command - args = ["run"] - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None if with_packages: - packages.update(pkg for pkg in with_packages if pkg) + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements.resolve()) if with_requirements else None, + project=str(project.resolve()) if project else None, + editable=str(with_editable.resolve()) if with_editable else None, + ) + args = env_config.build_uv_args() # Build server spec from parsed components if server_object: @@ -289,7 +273,7 @@ async def cursor_command( cyclopts.Parameter( "--with", help="Additional packages to install", - negative=False, + negative="", ), ] = [], env_vars: Annotated[ @@ -297,7 +281,7 @@ async def cursor_command( cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format", - negative=False, + negative="", ), ] = [], env_file: Annotated[ diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index b5145c18d..be9443fa1 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -9,6 +9,7 @@ import cyclopts import pyperclip from rich import print +from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -47,31 +48,22 @@ def install_mcp_json( True if generation was successful, False otherwise """ try: - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None if with_packages: - packages.update(pkg for pkg in with_packages if pkg) + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + editable=str(with_editable) if with_editable else None, + ) + args = env_config.build_uv_args() # Build server spec from parsed components if server_object: @@ -135,7 +127,7 @@ async def mcp_json_command( cyclopts.Parameter( "--with", help="Additional packages to install", - negative=False, + negative="", ), ] = [], env_vars: Annotated[ @@ -143,7 +135,7 @@ async def mcp_json_command( cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format", - negative=False, + negative="", ), ] = [], env_file: Annotated[ @@ -158,7 +150,7 @@ async def mcp_json_command( cyclopts.Parameter( "--copy", help="Copy configuration to clipboard instead of printing to stdout", - negative=False, + negative="", ), ] = False, python: Annotated[ diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 9745801a9..2f92fbd89 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -1,12 +1,15 @@ """Shared utilities for install commands.""" +import json import sys from pathlib import Path from dotenv import dotenv_values +from pydantic import ValidationError from rich import print -from fastmcp.cli.run import import_server, parse_file_path +from fastmcp.utilities.fastmcp_config import FastMCPConfig +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -34,31 +37,46 @@ async def process_common_args( Handles both fastmcp.json config files and traditional file.py:object syntax. """ - # Check if server_spec is a fastmcp.json file - if server_spec.endswith("fastmcp.json") or "fastmcp.json" in Path(server_spec).name: - from fastmcp.utilities.fastmcp_config import FastMCPConfig - + # Create FastMCPConfig from server_spec + config = None + if server_spec.endswith(".json"): config_path = Path(server_spec).resolve() if not config_path.exists(): print(f"[red]Configuration file not found: {config_path}[/red]") sys.exit(1) - # Load config and get entrypoint - config = FastMCPConfig.from_file(config_path) - entrypoint = config.get_entrypoint(config_path) + try: + with open(config_path) as f: + data = json.load(f) - # Convert to file and server_object - file = Path(entrypoint.file) - server_object = entrypoint.object + # Check if it's an MCPConfig (has mcpServers key) + if "mcpServers" in data: + # MCPConfig files aren't supported for install + print("[red]MCPConfig files are not supported for installation[/red]") + sys.exit(1) + else: + # It's a FastMCPConfig + config = FastMCPConfig.from_file(config_path) - # Merge packages from config if not overridden - if config.environment and config.environment.dependencies: - # Merge with CLI packages (CLI takes precedence) - config_packages = config.environment.dependencies or [] - with_packages = list(set(with_packages + config_packages)) + # Merge packages from config if not overridden + if config.environment and config.environment.dependencies: + # Merge with CLI packages (CLI takes precedence) + config_packages = list(config.environment.dependencies) or [] + with_packages = list(set(with_packages + config_packages)) + except (json.JSONDecodeError, ValidationError) as e: + print(f"[red]Invalid configuration file: {e}[/red]") + sys.exit(1) else: - # Parse traditional server spec - file, server_object = parse_file_path(server_spec) + # Create config from file path + source = FileSystemSource(path=server_spec) + config = FastMCPConfig(source=source) + + # Extract file and server_object from the source + # The FileSystemSource handles parsing path:object syntax + file = Path(config.source.path).resolve() + server_object = ( + config.source.entrypoint if hasattr(config.source, "entrypoint") else None + ) logger.debug( "Installing server", @@ -75,7 +93,7 @@ async def process_common_args( server = None if not name: try: - server = await import_server(file, server_object) + server = await config.source.load_server() name = server.name except (ImportError, ModuleNotFoundError) as e: logger.debug( diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index a78f8193e..d403d06a9 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -1,25 +1,23 @@ """FastMCP run command implementation with enhanced type hints.""" -import importlib.util -import inspect import json import re import subprocess import sys -from functools import partial from pathlib import Path from typing import Any, Literal from mcp.server.fastmcp import FastMCP as FastMCP1x +from pydantic import ValidationError from fastmcp.server.server import FastMCP from fastmcp.utilities.fastmcp_config import ( - DeploymentConfig, - EntrypointConfig, - EnvironmentConfig, + Environment, FastMCPConfig, ) +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger("cli.run") @@ -34,150 +32,6 @@ def is_url(path: str) -> bool: return bool(url_pattern.match(path)) -def parse_file_path(server_spec: str) -> tuple[Path, str | None]: - """Parse a file path that may include a server object specification. - - Args: - server_spec: Path to file, optionally with :object suffix - - Returns: - Tuple of (file_path, server_object) - """ - # First check if we have a Windows path (e.g., C:\...) - has_windows_drive = len(server_spec) > 1 and server_spec[1] == ":" - - # Split on the last colon, but only if it's not part of the Windows drive letter - # and there's actually another colon in the string after the drive letter - if ":" in (server_spec[2:] if has_windows_drive else server_spec): - file_str, server_object = server_spec.rsplit(":", 1) - else: - file_str, server_object = server_spec, None - - # Resolve the file path - file_path = Path(file_str).expanduser().resolve() - if not file_path.exists(): - logger.error(f"File not found: {file_path}") - sys.exit(1) - if not file_path.is_file(): - logger.error(f"Not a file: {file_path}") - sys.exit(1) - - return file_path, server_object - - -async def import_server(file: Path, server_or_factory: str | None = None) -> Any: - """Import a MCP server from a file. - - Args: - file: Path to the file - server_or_factory: Optional object name in format "module:object" or just "object" - - Returns: - The server object (or result of calling a factory function) - """ - # Add parent directory to Python path so imports can be resolved - file_dir = str(file.parent) - if file_dir not in sys.path: - sys.path.insert(0, file_dir) - - # Import the module - spec = importlib.util.spec_from_file_location("server_module", file) - if not spec or not spec.loader: - logger.error("Could not load module", extra={"file": str(file)}) - sys.exit(1) - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - # If no object specified, try common server names - if not server_or_factory: - # Look for common server instance names - for name in ["mcp", "server", "app"]: - if hasattr(module, name): - obj = getattr(module, name) - if isinstance(obj, FastMCP | FastMCP1x): - return await _resolve_server_or_factory(obj, file, name) - - logger.error( - f"No server object found in {file}. Please either:\n" - "1. Use a standard variable name (mcp, server, or app)\n" - "2. Specify the object name in fastmcp.json or use `file.py:object` syntax as your path.", - extra={"file": str(file)}, - ) - sys.exit(1) - - # Handle module:object syntax - if server_or_factory and ":" in server_or_factory: - module_name, object_name = server_or_factory.split(":", 1) - try: - server_module = importlib.import_module(module_name) - obj = getattr(server_module, object_name, None) - except ImportError: - logger.error( - f"Could not import module '{module_name}'", - extra={"file": str(file)}, - ) - sys.exit(1) - else: - # Just object name - obj = getattr(module, server_or_factory, None) - - if obj is None: - logger.error( - f"Server object '{server_or_factory}' not found", - extra={"file": str(file)}, - ) - sys.exit(1) - - return await _resolve_server_or_factory(obj, file, server_or_factory) - - -async def _resolve_server_or_factory(obj: Any, file: Path, name: str) -> Any: - """Resolve a server object or factory function to a server instance. - - Args: - obj: The object that might be a server or factory function - file: Path to the file for error messages - name: Name of the object for error messages - - Returns: - A server instance - """ - # Check if it's a function or coroutine function - if inspect.isfunction(obj) or inspect.iscoroutinefunction(obj): - logger.debug(f"Found factory function '{name}' in {file}") - - try: - if inspect.iscoroutinefunction(obj): - # Async factory function - server = await obj() - else: - # Sync factory function - server = obj() - - # Validate the result is a FastMCP server - if not isinstance(server, FastMCP | FastMCP1x): - logger.error( - f"Factory function '{name}' must return a FastMCP server instance, " - f"got {type(server).__name__}", - extra={"file": str(file)}, - ) - sys.exit(1) - - logger.debug(f"Factory function '{name}' created server: {server.name}") - return server - - except Exception as e: - logger.error( - f"Failed to call factory function '{name}': {e}", - extra={"file": str(file)}, - ) - sys.exit(1) - - # Not a function, return as-is (should be a server instance) - return obj - - def run_with_uv( server_spec: str, python_version: str | None = None, @@ -190,6 +44,7 @@ def run_with_uv( path: str | None = None, log_level: LogLevelType | None = None, show_banner: bool = True, + editable: str | None = None, ) -> None: """Run a MCP server using uv run subprocess. @@ -206,80 +61,83 @@ def run_with_uv( log_level: Log level show_banner: Whether to show the server banner """ - # Check if server_spec is a fastmcp.json file - if server_spec.endswith("fastmcp.json") or "fastmcp.json" in Path(server_spec).name: + # Check if server_spec is a .json file + if server_spec.endswith(".json"): config_path = Path(server_spec).resolve() # Get absolute path if config_path.exists(): - # Load config - config = FastMCPConfig.from_file(config_path) + # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig + try: + with open(config_path) as f: + data = json.load(f) - # Get entrypoint with resolved paths - entrypoint = config.get_entrypoint(config_path) - if entrypoint.object: - server_spec = f"{entrypoint.file}:{entrypoint.object}" - else: - server_spec = entrypoint.file + # Check if it's an MCPConfig first (has canonical mcpServers key) + if "mcpServers" in data: + # It's an MCPConfig, we don't process it here - just pass through + pass + else: + # Try to parse as FastMCPConfig + try: + adapter = get_cached_typeadapter(FastMCPConfig) + config: FastMCPConfig = adapter.validate_python(data) - # Merge environment config with CLI args - # Check if environment has any non-None values - if config.environment and any( - getattr(config.environment, field, None) is not None - for field in EnvironmentConfig.model_fields - ): - merged_env = config.environment.merge_with_cli_args( - python=python_version, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - ) - python_version = merged_env["python"] - with_packages = merged_env["with_packages"] - with_requirements = merged_env["with_requirements"] - project = merged_env["project"] + # Apply deployment settings + if config.deployment: + config.deployment.apply_runtime_settings(config_path) - # Merge deployment config with CLI args - # Check if deployment has any non-None values - if config.deployment and any( - getattr(config.deployment, field, None) is not None - for field in DeploymentConfig.model_fields - ): - merged_deploy = config.deployment.merge_with_cli_args( - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - ) - transport = merged_deploy["transport"] - host = merged_deploy["host"] - port = merged_deploy["port"] - path = merged_deploy["path"] - log_level = merged_deploy["log_level"] - cmd = ["uv", "run"] + # Merge environment config with CLI args (CLI takes precedence) + if config.environment: + # Use CLI values if provided, otherwise fall back to config + python_version = python_version or config.environment.python + project = project or ( + Path(config.environment.project) + if config.environment.project + else None + ) + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) + editable = editable or config.environment.editable - # Add Python version if specified - if python_version: - cmd.extend(["--python", python_version]) + # Merge packages from both sources + # Only merge if with_packages doesn't already contain them + # (they may have been merged already in CLI) + if config.environment.dependencies and not with_packages: + with_packages = list(config.environment.dependencies) - # Add project if specified - if project: - cmd.extend(["--project", str(project)]) + # Merge deployment config with CLI args (CLI takes precedence) + if config.deployment: + transport = transport or config.deployment.transport + host = host or config.deployment.host + port = port or config.deployment.port + path = path or config.deployment.path + log_level = log_level or config.deployment.log_level + except ValidationError: + # Not a valid FastMCPConfig, just pass through + pass + except (json.JSONDecodeError, FileNotFoundError): + # Not a valid JSON file, just pass through + pass - # Add fastmcp package - cmd.extend(["--with", "fastmcp"]) - - # Add additional packages - if with_packages: - for pkg in with_packages: - if pkg: - cmd.extend(["--with", pkg]) - - # Add requirements file - if with_requirements: - cmd.extend(["--with-requirements", str(with_requirements)]) - - # Add fastmcp run command - cmd.extend(["fastmcp", "run", server_spec]) + # Build uv command using Environment.build_uv_args() + env_config = Environment( + python=python_version, + dependencies=with_packages if with_packages else None, + requirements=str(with_requirements.resolve()) if with_requirements else None, + project=str(project.resolve()) if project else None, + editable=editable, + ) + # IMPORTANT: We add --skip-env to prevent infinite recursion. + # When this function executes `uv run ... fastmcp run server.py`, the inner + # `fastmcp run` command will be executed inside the uv environment we're creating. + # Without --skip-env, that inner command would detect it needs uv (due to the same + # CLI args) and try to spawn ANOTHER uv subprocess, creating infinite recursion. + # The --skip-env flag tells the inner fastmcp: "skip environment setup, we're already + # inside the uv environment that was just created for us." + cmd = ["uv"] + env_config.build_uv_args( + ["fastmcp", "run", server_spec, "--skip-env"] + ) # Add transport options if transport: @@ -336,16 +194,14 @@ def create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]: return server -def load_fastmcp_config( - config_path: Path, -) -> tuple[EntrypointConfig, DeploymentConfig | None, EnvironmentConfig | None]: +def load_fastmcp_config(config_path: Path) -> FastMCPConfig: """Load a FastMCP configuration from a fastmcp.json file. Args: config_path: Path to fastmcp.json file Returns: - Tuple of (entrypoint, deployment config, environment config) + FastMCPConfig object """ config = FastMCPConfig.from_file(config_path) @@ -353,55 +209,7 @@ def load_fastmcp_config( if config.deployment: config.deployment.apply_runtime_settings(config_path) - # Get entrypoint as structured object with resolved paths - entrypoint = config.get_entrypoint(config_path) - - # Return None for empty configs (backward compatibility) - deployment = ( - config.deployment - if any( - getattr(config.deployment, field, None) is not None - for field in DeploymentConfig.model_fields - ) - else None - ) - - environment = ( - config.environment - if any( - getattr(config.environment, field, None) is not None - for field in EnvironmentConfig.model_fields - ) - else None - ) - - return entrypoint, deployment, environment - - -async def import_server_with_args( - file: Path, - server_or_factory: str | None = None, - server_args: list[str] | None = None, -) -> Any: - """Import a server with optional command line arguments. - - Args: - file: Path to the server file - server_or_factory: Optional server object or factory function name - server_args: Optional command line arguments to inject - - Returns: - The imported server object - """ - if server_args: - original_argv = sys.argv[:] - try: - sys.argv = [str(file)] + server_args - return await import_server(file, server_or_factory) - finally: - sys.argv = original_argv - else: - return await import_server(file, server_or_factory) + return config async def run_command( @@ -414,6 +222,7 @@ async def run_command( server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, + skip_source: bool = False, ) -> None: """Run a MCP server or connect to a remote one. @@ -427,49 +236,76 @@ async def run_command( server_args: Additional arguments to pass to the server show_banner: Whether to show the server banner use_direct_import: Whether to use direct import instead of subprocess + skip_source: Whether to skip source preparation step """ + # Special case: URLs if is_url(server_spec): # Handle URL case server = create_client_server(server_spec) logger.debug(f"Created client proxy server for {server_spec}") - elif ( - server_spec.endswith("fastmcp.json") or "fastmcp.json" in Path(server_spec).name - ): - # Handle fastmcp.json configuration file (matches test_fastmcp.json, my.fastmcp.json, etc) - config_path = Path(server_spec) - entrypoint, deployment, environment = load_fastmcp_config(config_path) - - # Merge deployment config with CLI arguments (CLI takes precedence) - if deployment: - merged = deployment.merge_with_cli_args( - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - server_args=server_args, - ) - transport = merged["transport"] - host = merged["host"] - port = merged["port"] - path = merged["path"] - log_level = merged["log_level"] - server_args = merged["server_args"] - - # Import the server from the structured entrypoint - file_path = Path(entrypoint.file) - server = await import_server_with_args( - file_path, entrypoint.object, server_args - ) - logger.debug(f'Found server "{server.name}" from config {config_path}') + # Special case: MCPConfig files (legacy) elif server_spec.endswith(".json"): - # Handle other JSON files as MCPConfig - server = create_mcp_config_server(Path(server_spec)) + # Load JSON and check which type of config it is + config_path = Path(server_spec) + with open(config_path) as f: + data = json.load(f) + + # Check if it's an MCPConfig first (has canonical mcpServers key) + if "mcpServers" in data: + # It's an MCP config + server = create_mcp_config_server(config_path) + else: + # It's a FastMCP config - load it properly + config = load_fastmcp_config(config_path) + + # Merge deployment config with CLI arguments (CLI takes precedence) + if config.deployment: + transport = transport or config.deployment.transport + host = host or config.deployment.host + port = port or config.deployment.port + path = path or config.deployment.path + log_level = log_level or config.deployment.log_level + server_args = ( + server_args if server_args is not None else config.deployment.args + ) + + # Prepare the source if needed (e.g., clone git repo, download from cloud) + if not skip_source: + await config.source.prepare() + + # Load the server using the source + from contextlib import nullcontext + + from fastmcp.cli.cli import with_argv + + # Use sys.argv context manager if deployment args specified + argv_context = with_argv(server_args) if server_args else nullcontext() + + with argv_context: + server = await config.source.load_server() + + logger.debug(f'Found server "{server.name}" from config {config_path}') else: - # Handle file case - file, server_or_factory = parse_file_path(server_spec) - server = await import_server_with_args(file, server_or_factory, server_args) - logger.debug(f'Found server "{server.name}" in {file}') + # Regular file case - create a FastMCPConfig with FileSystemSource + source = FileSystemSource(path=server_spec) + config = FastMCPConfig(source=source) + + # Prepare the source if needed + if not skip_source: + await config.source.prepare() + + # Load the server + from contextlib import nullcontext + + from fastmcp.cli.cli import with_argv + + # Use sys.argv context manager if server_args specified + argv_context = with_argv(server_args) if server_args else nullcontext() + + with argv_context: + server = await config.source.load_server() + + logger.debug(f'Found server "{server.name}" in {source.path}') # Run the server @@ -506,6 +342,8 @@ def run_v1_server( port: int | None = None, transport: TransportType | None = None, ) -> None: + from functools import partial + if host: server.settings.host = host if port: diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index f2418417a..88a8a9d33 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -217,8 +217,13 @@ class OAuth(OAuthClientProvider): self.redirect_port = callback_port or find_available_port() redirect_uri = f"http://localhost:{self.redirect_port}/callback" + scopes_str: str if isinstance(scopes, list): - scopes = " ".join(scopes) + scopes_str = " ".join(scopes) + elif scopes is not None: + scopes_str = str(scopes) + else: + scopes_str = "" client_metadata = OAuthClientMetadata( client_name=client_name, @@ -226,7 +231,7 @@ class OAuth(OAuthClientProvider): grant_types=["authorization_code", "refresh_token"], response_types=["code"], # token_endpoint_auth_method="client_secret_post", - scope=scopes, + scope=scopes_str, **(additional_client_metadata or {}), ) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index fa68cf4c5..fc1c5f2b3 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import copy import datetime +import secrets from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass, field from pathlib import Path @@ -212,6 +213,7 @@ class Client(Generic[ClientTransportT]): | dict[str, Any] | str ), + name: str | None = None, roots: RootsList | RootsHandler | None = None, sampling_handler: ClientSamplingHandler | None = None, elicitation_handler: ElicitationHandler | None = None, @@ -223,6 +225,8 @@ class Client(Generic[ClientTransportT]): client_info: mcp.types.Implementation | None = None, auth: httpx.Auth | Literal["oauth"] | str | None = None, ) -> None: + self.name = name or self.generate_name() + self.transport = cast(ClientTransportT, infer_transport(transport)) if auth is not None: self.transport._set_auth(auth) @@ -236,7 +240,7 @@ class Client(Generic[ClientTransportT]): self._progress_handler = progress_handler if isinstance(timeout, int | float): - timeout = datetime.timedelta(seconds=timeout) + timeout = datetime.timedelta(seconds=float(timeout)) # handle init handshake timeout if init_timeout is None: @@ -339,6 +343,8 @@ class Client(Generic[ClientTransportT]): # Reset session state to fresh state new_client._session_state = ClientSessionState() + new_client.name += f":{secrets.token_hex(2)}" + return new_client @asynccontextmanager @@ -538,6 +544,8 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ + logger.debug(f"[{self.name}] called list_resources") + result = await self.session.list_resources() return result @@ -565,6 +573,8 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ + logger.debug(f"[{self.name}] called list_resource_templates") + result = await self.session.list_resource_templates() return result @@ -597,6 +607,8 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ + logger.debug(f"[{self.name}] called read_resource: {uri}") + if isinstance(uri, str): uri = AnyUrl(uri) # Ensure AnyUrl result = await self.session.read_resource(uri) @@ -651,6 +663,8 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ + logger.debug(f"[{self.name}] called list_prompts") + result = await self.session.list_prompts() return result @@ -683,6 +697,8 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ + logger.debug(f"[{self.name}] called get_prompt: {name}") + # Serialize arguments for MCP protocol - convert non-string values to JSON serialized_arguments: dict[str, str] | None = None if arguments: @@ -740,6 +756,8 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ + logger.debug(f"[{self.name}] called complete: {ref}") + result = await self.session.complete(ref=ref, argument=argument) return result @@ -775,6 +793,8 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ + logger.debug(f"[{self.name}] called list_tools") + result = await self.session.list_tools() return result @@ -817,9 +837,10 @@ class Client(Generic[ClientTransportT]): Raises: RuntimeError: If called while the client is not connected. """ + logger.debug(f"[{self.name}] called call_tool: {name}") if isinstance(timeout, int | float): - timeout = datetime.timedelta(seconds=timeout) + timeout = datetime.timedelta(seconds=float(timeout)) result = await self.session.call_tool( name=name, arguments=arguments, @@ -889,7 +910,7 @@ class Client(Generic[ClientTransportT]): else: data = result.structuredContent except Exception as e: - logger.error(f"Error parsing structured content: {e}") + logger.error(f"[{self.name}] Error parsing structured content: {e}") return CallToolResult( content=result.content, @@ -898,6 +919,14 @@ class Client(Generic[ClientTransportT]): is_error=result.isError, ) + @classmethod + def generate_name(cls, name: str | None = None) -> str: + class_name = cls.__name__ + if name is None: + return f"{class_name}-{secrets.token_hex(2)}" + else: + return f"{class_name}-{name}-{secrets.token_hex(2)}" + @dataclass class CallToolResult: diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 6600f7619..ec94a1f17 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -36,7 +36,7 @@ from fastmcp.client.auth.oauth import OAuth from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP -from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import EnvironmentConfig +from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -182,7 +182,7 @@ class SSETransport(ClientTransport): self.httpx_client_factory = httpx_client_factory if isinstance(sse_read_timeout, int | float): - sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) + sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout)) self.sse_read_timeout = sse_read_timeout def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): @@ -252,7 +252,7 @@ class StreamableHttpTransport(ClientTransport): self.httpx_client_factory = httpx_client_factory if isinstance(sse_read_timeout, int | float): - sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) + sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout)) self.sse_read_timeout = sse_read_timeout def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): @@ -596,8 +596,8 @@ class UvStdioTransport(StdioTransport): f"Project directory not found: {project_directory}" ) - # Create EnvironmentConfig from provided parameters (internal use) - env_config = EnvironmentConfig( + # Create Environment from provided parameters (internal use) + env_config = Environment( python=python_version, dependencies=with_packages, requirements=with_requirements, @@ -902,7 +902,8 @@ class MCPConfigTransport(ClientTransport): # otherwise create a composite client else: - self._composite_server = FastMCP[Any]() + name = FastMCP.generate_name("MCPRouter") + self._composite_server = FastMCP[Any](name=name) for name, server, transport in mcp_config_to_servers_and_transports( self.config @@ -1024,28 +1025,36 @@ def infer_transport( # the transport is a FastMCP server (2.x or 1.0) elif isinstance(transport, FastMCP | FastMCP1Server): - inferred_transport = FastMCPTransport(mcp=transport) + inferred_transport = FastMCPTransport( + mcp=cast(FastMCP[Any] | FastMCP1Server, transport) + ) # the transport is a path to a script elif isinstance(transport, Path | str) and Path(transport).exists(): if str(transport).endswith(".py"): - inferred_transport = PythonStdioTransport(script_path=transport) + inferred_transport = PythonStdioTransport(script_path=cast(Path, transport)) elif str(transport).endswith(".js"): - inferred_transport = NodeStdioTransport(script_path=transport) + inferred_transport = NodeStdioTransport(script_path=cast(Path, transport)) else: raise ValueError(f"Unsupported script type: {transport}") # the transport is an http(s) URL elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"): - inferred_transport_type = infer_transport_type_from_url(transport) + inferred_transport_type = infer_transport_type_from_url( + cast(AnyUrl | str, transport) + ) if inferred_transport_type == "sse": - inferred_transport = SSETransport(url=transport) + inferred_transport = SSETransport(url=cast(AnyUrl | str, transport)) else: - inferred_transport = StreamableHttpTransport(url=transport) + inferred_transport = StreamableHttpTransport( + url=cast(AnyUrl | str, transport) + ) # if the transport is a config dict or MCPConfig elif isinstance(transport, dict | MCPConfig): - inferred_transport = MCPConfigTransport(config=transport) + inferred_transport = MCPConfigTransport( + config=cast(dict | MCPConfig, transport) + ) # the transport is an unknown type else: diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py index a2ca5ee66..47e248c76 100644 --- a/src/fastmcp/mcp_config.py +++ b/src/fastmcp/mcp_config.py @@ -27,7 +27,7 @@ from __future__ import annotations import datetime import re from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal, cast from urllib.parse import urlparse import httpx @@ -91,14 +91,24 @@ class _TransformingMCPServerMixin(FastMCPBaseModel): def _to_server_and_underlying_transport( self, + server_name: str | None = None, + client_name: str | None = None, ) -> tuple[FastMCP[Any], ClientTransport]: """Turn the Transforming MCPServer into a FastMCP Server and also return the underlying transport.""" from fastmcp import FastMCP + from fastmcp.client import Client + from fastmcp.client.transports import ( + ClientTransport, # pyright: ignore[reportUnusedImport] + ) transport: ClientTransport = super().to_transport() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] + transport = cast(ClientTransport, transport) + + client: Client[ClientTransport] = Client(transport=transport, name=client_name) wrapped_mcp_server = FastMCP.as_proxy( - transport, + name=server_name, + backend=client, tool_transformations=self.tools, include_tags=self.include_tags, exclude_tags=self.exclude_tags, diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 862482e2a..9b2cf0cd0 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -43,6 +43,7 @@ from starlette.responses import JSONResponse, RedirectResponse from starlette.routing import Route from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier +from fastmcp.server.auth.redirect_validation import validate_redirect_uri from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: @@ -52,7 +53,7 @@ logger = get_logger(__name__) class ProxyDCRClient(OAuthClientInformationFull): - """Client for DCR proxy that accepts any localhost redirect URI. + """Client for DCR proxy with configurable redirect URI validation. This special client class is critical for the OAuth proxy to work correctly with Dynamic Client Registration (DCR). Here's why it exists: @@ -61,36 +62,48 @@ class ProxyDCRClient(OAuthClientInformationFull): -------- When MCP clients use OAuth, they dynamically register with random localhost ports (e.g., http://localhost:55454/callback). The OAuth proxy needs to: - 1. Accept these dynamic redirect URIs from clients + 1. Accept these dynamic redirect URIs from clients based on configured patterns 2. Use its own fixed redirect URI with the upstream provider (Google, GitHub, etc.) 3. Forward the authorization code back to the client's dynamic URI Solution: --------- - This class overrides redirect_uri validation to accept ANY localhost URI, + This class validates redirect URIs against configurable patterns, while the proxy internally uses its own fixed redirect URI with the upstream provider. This allows the flow to work even when clients reconnect with different ports or when tokens are cached. - Without this class, clients would get "Redirect URI not registered" errors - when trying to authenticate with cached tokens, because the stored client - would have fixed redirect URIs that don't match the new dynamic port. + Without proper validation, clients could get "Redirect URI not registered" errors + when trying to authenticate with cached tokens, or security vulnerabilities could + arise from accepting arbitrary redirect URIs. """ + def __init__( + self, *args, allowed_redirect_uri_patterns: list[str] | None = None, **kwargs + ): + """Initialize with allowed redirect URI patterns. + + Args: + allowed_redirect_uri_patterns: List of allowed redirect URI patterns with wildcard support. + If None, defaults to localhost-only patterns. + If empty list, allows all redirect URIs. + """ + super().__init__(*args, **kwargs) + self._allowed_redirect_uri_patterns = allowed_redirect_uri_patterns + def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: - """Accept any localhost redirect URI for DCR clients. + """Validate redirect URI against allowed patterns. Since we're acting as a proxy and clients register dynamically, - we need to accept their localhost redirect URIs even though they're - not pre-registered with us. This is essential for cached token - scenarios where the client may reconnect with a different port. + we validate their redirect URIs against configurable patterns. + This is essential for cached token scenarios where the client may + reconnect with a different port. """ if redirect_uri is not None: - # Accept any localhost redirect URI for DCR clients - uri_str = str(redirect_uri) - if uri_str.startswith(("http://localhost", "http://127.0.0.1")): + # Validate against allowed patterns + if validate_redirect_uri(redirect_uri, self._allowed_redirect_uri_patterns): return redirect_uri - # Fall back to normal validation for non-localhost URIs + # Fall back to normal validation if not in allowed patterns return super().validate_redirect_uri(redirect_uri) # If no redirect_uri provided, use default behavior return super().validate_redirect_uri(redirect_uri) @@ -229,6 +242,8 @@ class OAuthProxy(OAuthProvider): issuer_url: AnyHttpUrl | str | None = None, service_documentation_url: AnyHttpUrl | str | None = None, resource_server_url: AnyHttpUrl | str | None = None, + # Client redirect URI validation + allowed_client_redirect_uris: list[str] | None = None, ): """Initialize the OAuth proxy provider. @@ -244,6 +259,11 @@ class OAuthProxy(OAuthProvider): issuer_url: Issuer URL for OAuth metadata (defaults to base_url) service_documentation_url: Optional service documentation URL resource_server_url: Resource server URL (defaults to base_url) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*"). + If None (default), only localhost redirect URIs are allowed. + If empty list, all redirect URIs are allowed (not recommended for production). + These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. """ # Convert string URLs to AnyHttpUrl for parent class base_url_parsed = ( @@ -302,6 +322,7 @@ class OAuthProxy(OAuthProvider): self._redirect_path = ( redirect_path if redirect_path.startswith("/") else f"/{redirect_path}" ) + self._allowed_client_redirect_uris = allowed_client_redirect_uris # Local state for DCR and token bookkeeping self._clients: dict[str, OAuthClientInformationFull] = {} @@ -353,9 +374,10 @@ class OAuthProxy(OAuthProvider): client_secret=None, redirect_uris=[ AnyUrl("http://localhost") - ], # Placeholder - we accept any localhost URI + ], # Placeholder, validation uses allowed_patterns grant_types=["authorization_code", "refresh_token"], token_endpoint_auth_method="none", + allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) logger.debug("Created ProxyDCRClient for unregistered client %s", client_id) @@ -386,7 +408,7 @@ class OAuthProxy(OAuthProvider): upstream_id = self._upstream_client_id upstream_secret = self._upstream_client_secret.get_secret_value() - # Create a ProxyDCRClient that accepts any localhost redirect URI + # Create a ProxyDCRClient with configured redirect URI validation proxy_client = ProxyDCRClient( client_id=upstream_id, client_secret=upstream_secret, @@ -394,17 +416,9 @@ class OAuthProxy(OAuthProvider): grant_types=client_info.grant_types or ["authorization_code", "refresh_token"], token_endpoint_auth_method="none", + allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) - # Modify the client_info object in place (framework ignores return values) - client_info.client_id = upstream_id - client_info.client_secret = upstream_secret - client_info.token_endpoint_auth_method = "none" - - # Ensure correct grant types - if not client_info.grant_types: - client_info.grant_types = ["authorization_code", "refresh_token"] - # Store the ProxyDCRClient using the upstream ID self._clients[upstream_id] = proxy_client diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index d4968b76f..dee1bb478 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -159,17 +159,20 @@ class JWTVerifierSettings(BaseSettings): @register_provider("JWT") class JWTVerifier(TokenVerifier): """ - JWT token verifier using public key or JWKS. + JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. - This verifier validates JWT tokens signed by an external issuer. It's ideal for - scenarios where you have a centralized identity provider (like Auth0, Okta, or - your own OAuth server) that issues JWTs, and your FastMCP server acts as a - resource server validating those tokens. + This verifier validates JWT tokens using various signing algorithms: + - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512): + Uses public/private key pairs. Ideal for external clients and services where + only the authorization server has the private key. + - **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both + signing and verification. Perfect for internal microservices and trusted + environments where the secret can be securely shared. Use this when: - - You have JWT tokens issued by an external service - - You want asymmetric key verification (public/private key pairs) - - You need JWKS support for automatic key rotation + - You have JWT tokens issued by an external service (asymmetric) + - You need JWKS support for automatic key rotation (asymmetric) + - You have internal microservices sharing a secret key (symmetric) - Your tokens contain standard OAuth scopes and claims """ @@ -188,11 +191,14 @@ class JWTVerifier(TokenVerifier): Initialize the JWT token verifier. Args: - public_key: PEM-encoded public key for verification - jwks_uri: URI to fetch JSON Web Key Set + public_key: For asymmetric algorithms (RS256, ES256, etc.): PEM-encoded public key. + For symmetric algorithms (HS256, HS384, HS512): The shared secret string. + jwks_uri: URI to fetch JSON Web Key Set (only for asymmetric algorithms) issuer: Expected issuer claim audience: Expected audience claim(s) - algorithm: JWT signing algorithm (default: RS256) + algorithm: JWT signing algorithm. Supported algorithms: + - Asymmetric: RS256/384/512, ES256/384/512, PS256/384/512 (default: RS256) + - Symmetric: HS256, HS384, HS512 required_scopes: Required scopes for all tokens resource_server_url: Resource server URL for TokenVerifier protocol """ diff --git a/src/fastmcp/server/auth/redirect_validation.py b/src/fastmcp/server/auth/redirect_validation.py new file mode 100644 index 000000000..597c7d23f --- /dev/null +++ b/src/fastmcp/server/auth/redirect_validation.py @@ -0,0 +1,70 @@ +"""Utilities for validating client redirect URIs in OAuth flows.""" + +import fnmatch + +from pydantic import AnyUrl + + +def matches_allowed_pattern(uri: str, pattern: str) -> bool: + """Check if a URI matches an allowed pattern with wildcard support. + + Patterns support * wildcard matching: + - http://localhost:* matches any localhost port + - http://127.0.0.1:* matches any 127.0.0.1 port + - https://*.example.com/* matches any subdomain of example.com + - https://app.example.com/auth/* matches any path under /auth/ + + Args: + uri: The redirect URI to validate + pattern: The allowed pattern (may contain wildcards) + + Returns: + True if the URI matches the pattern + """ + # Use fnmatch for wildcard matching + return fnmatch.fnmatch(uri, pattern) + + +def validate_redirect_uri( + redirect_uri: str | AnyUrl | None, + allowed_patterns: list[str] | None, +) -> bool: + """Validate a redirect URI against allowed patterns. + + Args: + redirect_uri: The redirect URI to validate + allowed_patterns: List of allowed patterns. If None, defaults to localhost. + If empty list, all URIs are allowed. + + Returns: + True if the redirect URI is allowed + """ + if redirect_uri is None: + return True # None is allowed (will use client's default) + + uri_str = str(redirect_uri) + + # If no patterns specified, default to localhost only + if allowed_patterns is None: + allowed_patterns = [ + "http://localhost:*", + "http://127.0.0.1:*", + ] + + # Empty list means allow all + if len(allowed_patterns) == 0: + return True + + # Check if URI matches any allowed pattern + for pattern in allowed_patterns: + if matches_allowed_pattern(uri_str, pattern): + return True + + return False + + +# Default patterns for localhost-only validation +DEFAULT_LOCALHOST_PATTERNS = [ + "http://localhost:*", + "http://127.0.0.1:*", +] diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 3c17fc3c2..ea97162d5 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -548,7 +548,7 @@ class Context: if isinstance(validated_data, ScalarElicitationType): return AcceptedElicitation[T](data=validated_data.value) else: - return AcceptedElicitation[T](data=validated_data) + return AcceptedElicitation[T](data=cast(T, validated_data)) elif result.content: raise ValueError( "Elicitation expected an empty response, but received: " diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 2f52a627b..57ec5facf 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -546,6 +546,8 @@ class ProxyClient(Client[ClientTransportT]): | str, **kwargs, ): + if "name" not in kwargs: + kwargs["name"] = self.generate_name() if "roots" not in kwargs: kwargs["roots"] = default_proxy_roots_handler if "sampling_handler" not in kwargs: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index efeba0e22..bc7788288 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -5,6 +5,7 @@ from __future__ import annotations import inspect import json import re +import secrets import warnings from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import ( @@ -197,8 +198,9 @@ class FastMCP(Generic[LifespanResultT]): lifespan = default_lifespan else: self._has_lifespan = True + # Generate random ID if no name provided self._mcp_server = LowLevelServer[LifespanResultT]( - name=name or "FastMCP", + name=name or self.generate_name(), version=version, instructions=instructions, lifespan=_lifespan_wrapper(self, lifespan), @@ -519,7 +521,7 @@ class FastMCP(Generic[LifespanResultT]): return routes async def _mcp_list_tools(self) -> list[MCPTool]: - logger.debug("Handler called: list_tools") + logger.debug(f"[{self.name}] Handler called: list_tools") async with fastmcp.server.context.Context(fastmcp=self): tools = await self._list_tools() @@ -563,7 +565,7 @@ class FastMCP(Generic[LifespanResultT]): return await self._apply_middleware(mw_context, _handler) async def _mcp_list_resources(self) -> list[MCPResource]: - logger.debug("Handler called: list_resources") + logger.debug(f"[{self.name}] Handler called: list_resources") async with fastmcp.server.context.Context(fastmcp=self): resources = await self._list_resources() @@ -608,7 +610,7 @@ class FastMCP(Generic[LifespanResultT]): return await self._apply_middleware(mw_context, _handler) async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]: - logger.debug("Handler called: list_resource_templates") + logger.debug(f"[{self.name}] Handler called: list_resource_templates") async with fastmcp.server.context.Context(fastmcp=self): templates = await self._list_resource_templates() @@ -653,7 +655,7 @@ class FastMCP(Generic[LifespanResultT]): return await self._apply_middleware(mw_context, _handler) async def _mcp_list_prompts(self) -> list[MCPPrompt]: - logger.debug("Handler called: list_prompts") + logger.debug(f"[{self.name}] Handler called: list_prompts") async with fastmcp.server.context.Context(fastmcp=self): prompts = await self._list_prompts() @@ -712,7 +714,9 @@ class FastMCP(Generic[LifespanResultT]): Returns: List of MCP Content objects containing the tool results """ - logger.debug("Handler called: call_tool %s with %s", key, arguments) + logger.debug( + f"[{self.name}] Handler called: call_tool %s with %s", key, arguments + ) async with fastmcp.server.context.Context(fastmcp=self): try: @@ -754,7 +758,7 @@ class FastMCP(Generic[LifespanResultT]): Delegates to _read_resource, which should be overridden by FastMCP subclasses. """ - logger.debug("Handler called: read_resource %s", uri) + logger.debug(f"[{self.name}] Handler called: read_resource %s", uri) async with fastmcp.server.context.Context(fastmcp=self): try: @@ -809,7 +813,9 @@ class FastMCP(Generic[LifespanResultT]): Delegates to _get_prompt, which should be overridden by FastMCP subclasses. """ - logger.debug("Handler called: get_prompt %s with %s", name, arguments) + logger.debug( + f"[{self.name}] Handler called: get_prompt %s with %s", name, arguments + ) async with fastmcp.server.context.Context(fastmcp=self): try: @@ -1027,7 +1033,7 @@ class FastMCP(Generic[LifespanResultT]): description=description, tags=tags, output_schema=output_schema, - annotations=annotations, + annotations=cast(ToolAnnotations | None, annotations), exclude_args=exclude_args, meta=meta, serializer=self._tool_serializer, @@ -1257,7 +1263,7 @@ class FastMCP(Generic[LifespanResultT]): mime_type=mime_type, tags=tags, enabled=enabled, - annotations=annotations, + annotations=cast(Annotations | None, annotations), meta=meta, ) self.add_template(template) @@ -1272,7 +1278,7 @@ class FastMCP(Generic[LifespanResultT]): mime_type=mime_type, tags=tags, enabled=enabled, - annotations=annotations, + annotations=cast(Annotations | None, annotations), meta=meta, ) self.add_resource(resource) @@ -1966,9 +1972,11 @@ class FastMCP(Generic[LifespanResultT]): self._prompt_manager.add_prompt(prompt) if prefix: - logger.debug(f"Imported server {server.name} with prefix '{prefix}'") + logger.debug( + f"[{self.name}] Imported server {server.name} with prefix '{prefix}'" + ) else: - logger.debug(f"Imported server {server.name}") + logger.debug(f"[{self.name}] Imported server {server.name}") @classmethod def from_openapi( @@ -2195,6 +2203,15 @@ class FastMCP(Generic[LifespanResultT]): return True + @classmethod + def generate_name(cls, name: str | None = None) -> str: + class_name = cls.__name__ + + if name is None: + return f"{class_name}-{secrets.token_hex(2)}" + else: + return f"{class_name}-{name}-{secrets.token_hex(2)}" + @dataclass class MountedServer: diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 1ecda5a08..91a0bb33b 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -146,6 +146,7 @@ class Settings(BaseSettings): test_mode: bool = False + log_enabled: bool = True log_level: LOG_LEVEL = "INFO" @field_validator("log_level", mode="before") @@ -314,7 +315,7 @@ class Settings(BaseSettings): Whether to include FastMCP meta in the server's MCP responses. If True, a `_fastmcp` key will be added to the `meta` field of all MCP component responses. This key will contain a dict of - various FastMCP-specific metadata, such as tags. + various FastMCP-specific metadata, such as tags. """ ), ), diff --git a/src/fastmcp/utilities/fastmcp_config/__init__.py b/src/fastmcp/utilities/fastmcp_config/__init__.py index db2682c95..28236fe42 100644 --- a/src/fastmcp/utilities/fastmcp_config/__init__.py +++ b/src/fastmcp/utilities/fastmcp_config/__init__.py @@ -5,17 +5,19 @@ The current version is v1, which is re-exported here for convenience. """ from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import ( - DeploymentConfig, - EntrypointConfig, - EnvironmentConfig, + Deployment, + Environment, FastMCPConfig, generate_schema, ) +from fastmcp.utilities.fastmcp_config.v1.sources.base import BaseSource +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource __all__ = [ + "BaseSource", + "Deployment", + "Environment", "FastMCPConfig", - "EntrypointConfig", - "EnvironmentConfig", - "DeploymentConfig", + "FileSystemSource", "generate_schema", ] diff --git a/src/fastmcp/utilities/fastmcp_config/v1/__init__.py b/src/fastmcp/utilities/fastmcp_config/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py index aeb1dac38..80c9761ac 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py +++ b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py @@ -15,36 +15,20 @@ from typing import TYPE_CHECKING, Any, Literal, overload from pydantic import BaseModel, Field, field_validator +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger logger = get_logger("cli.config") # JSON Schema for IDE support -FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/schemas/fastmcp_config/v1.json" +FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" -class EntrypointConfig(BaseModel): - """Configuration for server entrypoint when using object format.""" - - file: str = Field( - description="Path to Python file containing the server", - examples=["server.py", "src/server.py", "app/main.py"], - ) - - object: str | None = Field( - default=None, - description="Name of the server object in the file (defaults to searching for mcp/server/app)", - examples=["app", "mcp", "server"], - ) - - repo: str | None = Field( - default=None, - description="Git repository URL", - examples=["https://github.com/user/repo"], - ) +# Type alias for source union (will expand with GitSource, etc in future) +SourceType = FileSystemSource -class EnvironmentConfig(BaseModel): +class Environment(BaseModel): """Configuration for Python environment setup.""" python: str | None = Field( @@ -96,13 +80,18 @@ class EnvironmentConfig(BaseModel): if self.project: args.extend(["--project", str(self.project)]) - # Add fastmcp as a base dependency - args.extend(["--with", "fastmcp"]) + # Add fastmcp dependency - use editable install if in development mode + dev_path = self._find_fastmcp_dev_path() + if dev_path: + args.extend(["--with-editable", str(dev_path)]) + else: + args.extend(["--with", "fastmcp"]) - # Add additional dependencies + # Add additional dependencies (skip fastmcp if already added) if self.dependencies: for dep in self.dependencies: - args.extend(["--with", dep]) + if dep != "fastmcp": # Skip fastmcp since we already added it + args.extend(["--with", dep]) # Add requirements file if self.requirements: @@ -121,6 +110,34 @@ class EnvironmentConfig(BaseModel): return args + def _find_fastmcp_dev_path(self) -> Path | None: + """Find the fastmcp development directory by looking for pyproject.toml. + + Searches from the current working directory up the directory tree + looking for a pyproject.toml file that contains name = "fastmcp". + + Returns: + Path to the fastmcp project directory if found, None otherwise + """ + current_path = Path.cwd() + + # Search up the directory tree + for path in [current_path] + list(current_path.parents): + pyproject_path = path / "pyproject.toml" + if pyproject_path.exists(): + try: + # Read and check if this is the fastmcp project + content = pyproject_path.read_text(encoding="utf-8") + if 'name = "fastmcp"' in content or "name='fastmcp'" in content: + logger.debug(f"Found fastmcp development project at: {path}") + return path + except (OSError, UnicodeDecodeError): + # Skip files that can't be read + continue + + logger.debug("No fastmcp development project found, using PyPI package") + return None + def run_with_uv(self, command: list[str]) -> None: """Execute a command using uv run with this environment configuration. @@ -150,51 +167,18 @@ class EnvironmentConfig(BaseModel): Returns: True if any environment settings require uv run """ - return bool( - self.python - or self.dependencies - or self.requirements - or self.project - or self.editable + return any( + [ + self.python is not None, + self.dependencies is not None, + self.requirements is not None, + self.project is not None, + self.editable is not None, + ] ) - def merge_with_cli_args( - self, - python: str | None = None, - with_packages: list[str] | None = None, - with_requirements: Path | None = None, - project: Path | None = None, - with_editable: Path | None = None, - ) -> dict[str, Any]: - """Merge environment config with CLI arguments, with CLI taking precedence. - For packages, combines both config and CLI packages. - For other fields, CLI takes precedence if provided. - - Returns: - Dictionary with merged arguments suitable for CLI commands - """ - from pathlib import Path - - # Merge packages from both sources - packages = [] - if self.dependencies: - packages.extend(self.dependencies) - if with_packages: - packages.extend(with_packages) - - return { - "python": python or self.python, - "with_packages": packages, - "with_requirements": with_requirements - or (Path(self.requirements) if self.requirements else None), - "project": project or (Path(self.project) if self.project else None), - "with_editable": with_editable - or (Path(self.editable) if self.editable else None), - } - - -class DeploymentConfig(BaseModel): +class Deployment(BaseModel): """Configuration for server deployment and runtime settings.""" transport: Literal["stdio", "http", "sse"] | None = Field( @@ -243,29 +227,6 @@ class DeploymentConfig(BaseModel): examples=[["--config", "config.json", "--debug"]], ) - def merge_with_cli_args( - self, - transport: str | None = None, - host: str | None = None, - port: int | None = None, - path: str | None = None, - log_level: str | None = None, - server_args: list[str] | None = None, - ) -> dict[str, Any]: - """Merge deployment config with CLI arguments, with CLI taking precedence. - - Returns: - Dictionary with merged arguments suitable for CLI commands - """ - return { - "transport": transport or self.transport, - "host": host or self.host, - "port": port or self.port, - "path": path or self.path, - "log_level": log_level or self.log_level, - "server_args": server_args if server_args is not None else self.args, - } - def apply_runtime_settings(self, config_path: Path | None = None) -> None: """Apply runtime settings like environment variables and working directory. @@ -324,149 +285,99 @@ class FastMCPConfig(BaseModel): # Schema field for IDE support schema_: str | None = Field( - default="https://gofastmcp.com/schemas/fastmcp_config/v1.json", + default="https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", alias="$schema", description="JSON schema for IDE support and validation", ) - # Server entrypoint - supports both string and object format - entrypoint: EntrypointConfig = Field( - description="Server entrypoint as a string (file or file:object) or object with file/object/repo", + # Server source - defines where and how to load the server + source: SourceType = Field( + description="Source configuration for the server", examples=[ - "server.py", - "server.py:app", - {"file": "src/server.py", "object": "app"}, + {"path": "server.py"}, + {"path": "server.py", "entrypoint": "app"}, + {"type": "filesystem", "path": "src/server.py", "entrypoint": "mcp"}, ], ) # Environment configuration - environment: EnvironmentConfig = Field( - default_factory=lambda: EnvironmentConfig(), + environment: Environment = Field( + default_factory=lambda: Environment(), description="Python environment setup configuration", ) # Deployment configuration - deployment: DeploymentConfig = Field( - default_factory=lambda: DeploymentConfig(), + deployment: Deployment = Field( + default_factory=lambda: Deployment(), description="Server deployment and runtime settings", ) - # purely for static type checkers to avoid issues with providng str entrypoint + # purely for static type checkers to avoid issues with providing dict source if TYPE_CHECKING: @overload - def __init__( - self, *, entrypoint: str | dict | EntrypointConfig, **data - ) -> None: ... + def __init__(self, *, source: dict | FileSystemSource, **data) -> None: ... @overload - def __init__( - self, *, environment: dict | EnvironmentConfig, **data - ) -> None: ... + def __init__(self, *, environment: dict | Environment, **data) -> None: ... @overload - def __init__(self, *, deployment: dict | DeploymentConfig, **data) -> None: ... + def __init__(self, *, deployment: dict | Deployment, **data) -> None: ... def __init__(self, **data) -> None: ... - @field_validator("entrypoint", mode="before") + @field_validator("source", mode="before") @classmethod - def validate_entrypoint(cls, v: str | EntrypointConfig) -> EntrypointConfig: - """Validate and convert entrypoint to proper format. + def validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource: + """Validate and convert source to proper format. Supports: - - String format: "server.py" or "server.py:object" - - Object format: {"file": "server.py", "object": "app"} - - EntrypointConfig instance (passed through) + - Dict format: {"path": "server.py", "entrypoint": "app"} + - FileSystemSource instance (passed through) - The string format with :object syntax is automatically parsed into - the object format for consistency. + No string parsing happens here - that's only at CLI boundaries. + FastMCPConfig works only with properly typed objects. """ - if isinstance(v, EntrypointConfig): - # Already an EntrypointConfig instance, return as-is + if isinstance(v, FileSystemSource): + # Already a FileSystemSource instance, return as-is return v elif isinstance(v, dict): - return EntrypointConfig(**v) - elif isinstance(v, str): - # Parse file.py:object syntax into object format if present - if ":" in v: - # Check if it's a Windows path (e.g., C:\...) - has_windows_drive = len(v) > 1 and v[1] == ":" - - # Only split if colon is not part of Windows drive - if ":" in (v[2:] if has_windows_drive else v): - file, obj = v.rsplit(":", 1) - return EntrypointConfig(file=file, object=obj) - else: - return EntrypointConfig(file=v) - - raise ValueError("entrypoint must be a string, EntrypointConfig instance") + # Dict can have type field or not (filesystem is default) + if "type" not in v: + v["type"] = "filesystem" + return FileSystemSource(**v) + else: + raise ValueError("source must be a dict or FileSystemSource instance") @field_validator("environment", mode="before") @classmethod - def validate_environment(cls, v: dict | EnvironmentConfig) -> EnvironmentConfig: - """Validate and convert environment to EnvironmentConfig. + def validate_environment(cls, v: dict | Environment) -> Environment: + """Validate and convert environment to Environment. Accepts: - - EnvironmentConfig instance - - dict that can be converted to EnvironmentConfig + - Environment instance + - dict that can be converted to Environment """ - if isinstance(v, EnvironmentConfig): + if isinstance(v, Environment): return v elif isinstance(v, dict): - return EnvironmentConfig(**v) # type: ignore[arg-type] + return Environment(**v) # type: ignore[arg-type] else: - raise ValueError("environment must be a dict, EnvironmentConfig instance") + raise ValueError("environment must be a dict, Environment instance") @field_validator("deployment", mode="before") @classmethod - def validate_deployment(cls, v: dict | DeploymentConfig) -> DeploymentConfig: - """Validate and convert deployment to DeploymentConfig. + def validate_deployment(cls, v: dict | Deployment) -> Deployment: + """Validate and convert deployment to Deployment. Accepts: - - DeploymentConfig instance - - dict that can be converted to DeploymentConfig + - Deployment instance + - dict that can be converted to Deployment """ - if isinstance(v, DeploymentConfig): + if isinstance(v, Deployment): return v elif isinstance(v, dict): - return DeploymentConfig(**v) # type: ignore[arg-type] + return Deployment(**v) # type: ignore[arg-type] else: - raise ValueError("deployment must be a dict, DeploymentConfig instance") - - def get_entrypoint(self, config_path: Path | None = None) -> EntrypointConfig: - """Get the entrypoint as a structured object with resolved paths. - - Args: - config_path: Path to config file for resolving relative paths - - Returns: - EntrypointConfig object with file, object, and repo fields. - If config_path is provided, relative file paths are resolved - relative to the config file location. - """ - if isinstance(self.entrypoint, str): - # Parse string format into structured object - if ":" in self.entrypoint: - file, obj = self.entrypoint.rsplit(":", 1) - entrypoint = EntrypointConfig(file=file, object=obj) - else: - entrypoint = EntrypointConfig(file=self.entrypoint) - else: - # Already an EntrypointConfig - entrypoint = self.entrypoint - - # Resolve relative paths if config_path provided - if config_path: - file_path = Path(entrypoint.file) - if not file_path.is_absolute(): - resolved_path = (config_path.parent / file_path).resolve() - # Create new EntrypointConfig with resolved path - entrypoint = EntrypointConfig( - file=str(resolved_path), - object=entrypoint.object, - repo=entrypoint.repo, - ) - - return entrypoint + raise ValueError("deployment must be a dict, Deployment instance") @classmethod def from_file(cls, file_path: Path) -> FastMCPConfig: @@ -494,7 +405,7 @@ class FastMCPConfig(BaseModel): @classmethod def from_cli_args( cls, - entrypoint: str, + source: FileSystemSource, transport: Literal["stdio", "http", "sse", "streamable-http"] | None = None, host: str | None = None, port: int | None = None, @@ -516,7 +427,7 @@ class FastMCPConfig(BaseModel): goes through a config object. Args: - entrypoint: Server entrypoint (file or file:object) + source: Server source (FileSystemSource instance) transport: Transport protocol host: Host for HTTP transport port: Port for HTTP transport @@ -537,7 +448,7 @@ class FastMCPConfig(BaseModel): # Build environment config if any env args provided environment = None if any([python, dependencies, requirements, project, editable]): - environment = EnvironmentConfig( + environment = Environment( python=python, dependencies=dependencies, requirements=requirements, @@ -551,7 +462,7 @@ class FastMCPConfig(BaseModel): # Convert streamable-http to http for backward compatibility if transport == "streamable-http": transport = "http" # type: ignore[assignment] - deployment = DeploymentConfig( + deployment = Deployment( transport=transport, # type: ignore[arg-type] host=host, port=port, @@ -563,7 +474,7 @@ class FastMCPConfig(BaseModel): ) return cls( - entrypoint=entrypoint, + source=source, environment=environment, deployment=deployment, ) @@ -588,48 +499,6 @@ class FastMCPConfig(BaseModel): return None - async def load_server(self, config_path: Path | None = None) -> Any: - """Load the server from the configuration. - - This handles environment setup, working directory changes, - and imports the server module. - - Args: - config_path: Path to the config file (for resolving relative paths) - - Returns: - The imported server object - """ - import os - from pathlib import Path - - # Set environment variables if specified - if self.deployment and self.deployment.env: - for key, value in self.deployment.env.items(): - os.environ[key] = value - - # Change working directory if specified - if self.deployment and self.deployment.cwd: - cwd_path = Path(self.deployment.cwd) - if not cwd_path.is_absolute(): - # If config_path provided, resolve relative to it - if config_path: - cwd_path = (config_path.parent / cwd_path).resolve() - else: - cwd_path = cwd_path.resolve() - os.chdir(cwd_path) - - # Get structured entrypoint with resolved paths - entrypoint = self.get_entrypoint(config_path) - - # Import the server - from fastmcp.cli.run import import_server_with_args - - file_path = Path(entrypoint.file) - server_args = self.deployment.args if self.deployment else None - - return await import_server_with_args(file_path, entrypoint.object, server_args) - async def run_server(self, **kwargs: Any) -> None: """Load and run the server with this configuration. @@ -637,7 +506,12 @@ class FastMCPConfig(BaseModel): **kwargs: Additional arguments to pass to server.run_async() These override config settings """ - server = await self.load_server() + # Apply deployment settings (env vars, cwd) + if self.deployment: + self.deployment.apply_runtime_settings() + + # Load the server + server = await self.source.load_server() # Build run arguments from config run_args = {} @@ -659,14 +533,19 @@ class FastMCPConfig(BaseModel): await server.run_async(**run_args) -def generate_schema() -> dict[str, Any]: +def generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None: """Generate JSON schema for fastmcp.json files. This is used to create the schema file that IDEs can use for validation and auto-completion. + Args: + output_path: Optional path to write the schema to. If provided, + writes the schema and returns None. If not provided, + returns the schema as a dictionary. + Returns: - JSON schema as a dictionary + JSON schema as a dictionary if output_path is None, otherwise None """ schema = FastMCPConfig.model_json_schema() @@ -675,4 +554,14 @@ def generate_schema() -> dict[str, Any]: schema["title"] = "FastMCP Configuration" schema["description"] = "Configuration file for FastMCP servers" + if output_path: + import json + + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + with open(output, "w") as f: + json.dump(schema, f, indent=2) + f.write("\n") # Add trailing newline + return None + return schema diff --git a/src/fastmcp/utilities/fastmcp_config/v1/schema.json b/src/fastmcp/utilities/fastmcp_config/v1/schema.json index a7c98df6e..81d0ad754 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/schema.json +++ b/src/fastmcp/utilities/fastmcp_config/v1/schema.json @@ -1,6 +1,6 @@ { "$defs": { - "DeploymentConfig": { + "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { @@ -159,64 +159,10 @@ "title": "Args" } }, - "title": "DeploymentConfig", + "title": "Deployment", "type": "object" }, - "EntrypointConfig": { - "description": "Configuration for server entrypoint when using object format.", - "properties": { - "file": { - "description": "Path to Python file containing the server", - "examples": [ - "server.py", - "src/server.py", - "app/main.py" - ], - "title": "File", - "type": "string" - }, - "object": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Name of the server object in the file (defaults to searching for mcp/server/app)", - "examples": [ - "app", - "mcp", - "server" - ], - "title": "Object" - }, - "repo": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Git repository URL", - "examples": [ - "https://github.com/user/repo" - ], - "title": "Repo" - } - }, - "required": [ - "file" - ], - "title": "EntrypointConfig", - "type": "object" - }, - "EnvironmentConfig": { + "Environment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -312,7 +258,42 @@ "title": "Editable" } }, - "title": "EnvironmentConfig", + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", "type": "object" } }, @@ -327,35 +308,41 @@ "type": "null" } ], - "default": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "default": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "description": "JSON schema for IDE support and validation", "title": "$Schema" }, - "entrypoint": { - "$ref": "#/$defs/EntrypointConfig", - "description": "Server entrypoint as a string (file or file:object) or object with file/object/repo", + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", "examples": [ - "server.py", - "server.py:app", { - "file": "src/server.py", - "object": "app" + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" } ] }, "environment": { - "$ref": "#/$defs/EnvironmentConfig", + "$ref": "#/$defs/Environment", "description": "Python environment setup configuration" }, "deployment": { - "$ref": "#/$defs/DeploymentConfig", + "$ref": "#/$defs/Deployment", "description": "Server deployment and runtime settings" } }, "required": [ - "entrypoint" + "source" ], "title": "FastMCP Configuration", "type": "object", - "$id": "https://gofastmcp.com/schemas/fastmcp_config/v1.json" -} \ No newline at end of file + "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" +} diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py b/src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py b/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py new file mode 100644 index 000000000..1eeb593d9 --- /dev/null +++ b/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractmethod +from typing import Any + +from pydantic import BaseModel, Field + + +class BaseSource(BaseModel, ABC): + """Abstract base class for all source types.""" + + type: str = Field(description="Source type identifier") + + async def prepare(self) -> None: + """Prepare the source (download, clone, install, etc). + + For sources that need preparation (e.g., git clone, download), + this method performs that preparation. For sources that don't + need preparation (e.g., local files), this is a no-op. + """ + # Default implementation for sources that don't need preparation + pass + + @abstractmethod + async def load_server(self) -> Any: + """Load and return the FastMCP server instance. + + Must be called after prepare() if the source requires preparation. + All information needed to load the server should be available + as attributes on the source instance. + """ + ... diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py b/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py new file mode 100644 index 000000000..92fb7ae3e --- /dev/null +++ b/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py @@ -0,0 +1,215 @@ +import importlib.util +import inspect +import sys +from pathlib import Path +from typing import Any, Literal + +from pydantic import Field, field_validator + +from fastmcp.utilities.fastmcp_config.v1.sources.base import BaseSource +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class FileSystemSource(BaseSource): + """Source for local Python files.""" + + type: Literal["filesystem"] = Field(default="filesystem", description="Source type") + path: str = Field(description="Path to Python file containing the server") + entrypoint: str | None = Field( + default=None, + description="Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + ) + + @field_validator("path", mode="before") + @classmethod + def parse_path_with_object(cls, v: str) -> str: + """Parse path:object syntax and extract the object name. + + This validator runs before the model is created, allowing us to + handle the "file.py:object" syntax at the model boundary. + """ + if isinstance(v, str) and ":" in v: + # Check if it's a Windows path (e.g., C:\...) + has_windows_drive = len(v) > 1 and v[1] == ":" + + # Only split if colon is not part of Windows drive + if ":" in (v[2:] if has_windows_drive else v): + # This path has an object specification + # We'll handle it in __init__ by setting entrypoint + return v + return v + + def __init__(self, **data: Any) -> None: + """Initialize FileSystemSource, handling path:object syntax.""" + # Check if path contains an object specification + if "path" in data and isinstance(data["path"], str) and ":" in data["path"]: + path_str = data["path"] + # Check if it's a Windows path (e.g., C:\...) + has_windows_drive = len(path_str) > 1 and path_str[1] == ":" + + # Only split if colon is not part of Windows drive + if ":" in (path_str[2:] if has_windows_drive else path_str): + file_str, obj = path_str.rsplit(":", 1) + data["path"] = file_str + # Only set entrypoint if not already provided + if "entrypoint" not in data or data["entrypoint"] is None: + data["entrypoint"] = obj + + super().__init__(**data) + + async def load_server(self) -> Any: + """Load server from filesystem.""" + # Resolve the file path + file_path = Path(self.path).expanduser().resolve() + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + sys.exit(1) + if not file_path.is_file(): + logger.error(f"Not a file: {file_path}") + sys.exit(1) + + # Import the module + module = self._import_module(file_path) + + # Find the server object + server = await self._find_server_object(module, file_path) + + return server + + def _import_module(self, file_path: Path) -> Any: + """Import a Python module from a file path. + + Args: + file_path: Path to the Python file + + Returns: + The imported module + """ + # Add parent directory to Python path so imports can be resolved + file_dir = str(file_path.parent) + if file_dir not in sys.path: + sys.path.insert(0, file_dir) + + # Import the module + spec = importlib.util.spec_from_file_location("server_module", file_path) + if not spec or not spec.loader: + logger.error("Could not load module", extra={"file": str(file_path)}) + sys.exit(1) + + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + sys.modules["server_module"] = module # Register in sys.modules + spec.loader.exec_module(module) # type: ignore[union-attr] + + return module + + async def _find_server_object(self, module: Any, file_path: Path) -> Any: + """Find the server object in the module. + + Args: + module: The imported Python module + file_path: Path to the file (for error messages) + + Returns: + The server object (or result of calling a factory function) + """ + # Avoid circular import by importing here + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.server.server import FastMCP + + # If entrypoint is specified, use it + if self.entrypoint: + # Handle module:object syntax (though this is legacy) + if ":" in self.entrypoint: + module_name, object_name = self.entrypoint.split(":", 1) + try: + import importlib + + server_module = importlib.import_module(module_name) + obj = getattr(server_module, object_name, None) + except ImportError: + logger.error( + f"Could not import module '{module_name}'", + extra={"file": str(file_path)}, + ) + sys.exit(1) + else: + # Just object name + obj = getattr(module, self.entrypoint, None) + + if obj is None: + logger.error( + f"Server object '{self.entrypoint}' not found", + extra={"file": str(file_path)}, + ) + sys.exit(1) + + return await self._resolve_factory(obj, file_path, self.entrypoint) + + # No entrypoint specified, try common server names + for name in ["mcp", "server", "app"]: + if hasattr(module, name): + obj = getattr(module, name) + if isinstance(obj, FastMCP | FastMCP1x): + return await self._resolve_factory(obj, file_path, name) + + # No server found + logger.error( + f"No server object found in {file_path}. Please either:\n" + "1. Use a standard variable name (mcp, server, or app)\n" + "2. Specify the entrypoint name in fastmcp.json or use `file.py:object` syntax as your path.", + extra={"file": str(file_path)}, + ) + sys.exit(1) + + async def _resolve_factory(self, obj: Any, file_path: Path, name: str) -> Any: + """Resolve a server object or factory function to a server instance. + + Args: + obj: The object that might be a server or factory function + file_path: Path to the file for error messages + name: Name of the object for error messages + + Returns: + A server instance + """ + # Avoid circular import by importing here + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.server.server import FastMCP + + # Check if it's a function or coroutine function + if inspect.isfunction(obj) or inspect.iscoroutinefunction(obj): + logger.debug(f"Found factory function '{name}' in {file_path}") + + try: + if inspect.iscoroutinefunction(obj): + # Async factory function + server = await obj() + else: + # Sync factory function + server = obj() + + # Validate the result is a FastMCP server + if not isinstance(server, FastMCP | FastMCP1x): + logger.error( + f"Factory function '{name}' must return a FastMCP server instance, " + f"got {type(server).__name__}", + extra={"file": str(file_path)}, + ) + sys.exit(1) + + logger.debug(f"Factory function '{name}' created server: {server.name}") + return server + + except Exception as e: + logger.error( + f"Failed to call factory function '{name}': {e}", + extra={"file": str(file_path)}, + ) + sys.exit(1) + + # Not a function, return as-is (should be a server instance) + return obj diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index 18c2c7e94..df38d679e 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -4,7 +4,7 @@ from __future__ import annotations import importlib.metadata from dataclasses import dataclass -from typing import Any +from typing import Any, cast from mcp.server.fastmcp import FastMCP as FastMCP1x @@ -318,4 +318,4 @@ async def inspect_fastmcp(mcp: FastMCP[Any] | FastMCP1x) -> FastMCPInfo: if isinstance(mcp, FastMCP1x): return await inspect_fastmcp_v1(mcp) else: - return await inspect_fastmcp_v2(mcp) + return await inspect_fastmcp_v2(cast(FastMCP[Any], mcp)) diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py index 48cb3fb9a..ef4da8a78 100644 --- a/src/fastmcp/utilities/mcp_config.py +++ b/src/fastmcp/utilities/mcp_config.py @@ -1,10 +1,16 @@ from typing import Any -from fastmcp.client.transports import ClientTransport +from fastmcp.client.transports import ( + ClientTransport, + SSETransport, + StdioTransport, + StreamableHttpTransport, +) from fastmcp.mcp_config import ( MCPConfig, MCPServerTypes, ) +from fastmcp.server.proxy import FastMCPProxy, ProxyClient from fastmcp.server.server import FastMCP @@ -23,6 +29,7 @@ def mcp_server_type_to_servers_and_transports( mcp_server: MCPServerTypes, ) -> tuple[str, FastMCP[Any], ClientTransport]: """A utility function to convert each entry of an MCP Config into a transport and server.""" + from fastmcp.mcp_config import ( TransformingRemoteMCPServer, TransformingStdioMCPServer, @@ -31,10 +38,19 @@ def mcp_server_type_to_servers_and_transports( server: FastMCP[Any] transport: ClientTransport + client_name = ProxyClient.generate_name(f"MCP_{name}") + server_name = FastMCPProxy.generate_name(f"MCP_{name}") + if isinstance(mcp_server, TransformingRemoteMCPServer | TransformingStdioMCPServer): - server, transport = mcp_server._to_server_and_underlying_transport() + server, transport = mcp_server._to_server_and_underlying_transport( + server_name=server_name, + client_name=client_name, + ) else: transport = mcp_server.to_transport() - server = FastMCP.as_proxy(backend=transport) + client: ProxyClient[StreamableHttpTransport | SSETransport | StdioTransport] = ( + ProxyClient(transport=transport, name=client_name) + ) + server = FastMCP.as_proxy(name=server_name, backend=client) return name, server, transport diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 5e2365c0d..f537a74f6 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -10,6 +10,7 @@ from pathlib import Path from types import EllipsisType, UnionType from typing import ( Annotated, + Any, Protocol, TypeAlias, TypeVar, @@ -122,7 +123,7 @@ def issubclass_safe(cls: type, base: type) -> bool: return False -def is_class_member_of_type(cls: type, base: type) -> bool: +def is_class_member_of_type(cls: Any, base: type) -> bool: """ Check if cls is a member of base, even if cls is a type variable. diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 4653c189c..3cc075098 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch import pytest -from fastmcp.cli.cli import _build_uv_command, _parse_env_var, app +from fastmcp.cli.cli import _parse_env_var, app class TestMainCLI: @@ -34,150 +34,6 @@ class TestMainCLI: _parse_env_var("INVALID_FORMAT") assert exc_info.value.code == 1 - def test_build_uv_command_basic(self): - """Test building basic uv command.""" - cmd = _build_uv_command("server.py") - expected = ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "server.py"] - assert cmd == expected - - def test_build_uv_command_with_editable(self): - """Test building uv command with editable package.""" - editable_path = Path("/path/to/package") - cmd = _build_uv_command("server.py", with_editable=editable_path) - expected = [ - "uv", - "run", - "--with", - "fastmcp", - "--with-editable", - str(editable_path), - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_with_packages(self): - """Test building uv command with additional packages.""" - cmd = _build_uv_command("server.py", with_packages=["pkg1", "pkg2"]) - expected = [ - "uv", - "run", - "--with", - "fastmcp", - "--with", - "pkg1", - "--with", - "pkg2", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_no_banner(self): - """Test building uv command with no banner flag.""" - cmd = _build_uv_command("server.py", no_banner=True) - expected = [ - "uv", - "run", - "--with", - "fastmcp", - "fastmcp", - "run", - "server.py", - "--no-banner", - ] - assert cmd == expected - - def test_build_uv_command_with_python_version(self): - """Test building uv command with Python version.""" - cmd = _build_uv_command("server.py", python_version="3.11") - expected = [ - "uv", - "run", - "--python", - "3.11", - "--with", - "fastmcp", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_with_project(self): - """Test building uv command with project directory.""" - project_path = Path("/path/to/project") - cmd = _build_uv_command("server.py", project=project_path) - expected = [ - "uv", - "run", - "--project", - str(project_path), - "--with", - "fastmcp", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_with_requirements(self): - """Test building uv command with requirements file.""" - req_path = Path("requirements.txt") - cmd = _build_uv_command("server.py", with_requirements=req_path) - expected = [ - "uv", - "run", - "--with", - "fastmcp", - "--with-requirements", - "requirements.txt", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_with_all_options(self): - """Test building uv command with all options.""" - project_path = Path("/my/project") - editable_path = Path("/local/pkg") - requirements_path = Path("reqs.txt") - cmd = _build_uv_command( - "server.py", - python_version="3.10", - project=project_path, - with_packages=["pandas", "numpy"], - with_requirements=requirements_path, - with_editable=editable_path, - no_banner=True, - ) - expected = [ - "uv", - "run", - "--python", - "3.10", - "--project", - str(project_path), - "--with", - "fastmcp", - "--with-editable", - str(editable_path), - "--with", - "pandas", - "--with", - "numpy", - "--with-requirements", - str(requirements_path), - "fastmcp", - "run", - "server.py", - "--no-banner", - ] - assert cmd == expected - class TestVersionCommand: """Test the version command.""" @@ -472,6 +328,47 @@ class TestRunCommand: ] ) + def test_run_command_parsing_skip_env_flag(self): + """Test run command parsing with --skip-env flag.""" + command, bound, _ = app.parse_args( + [ + "run", + "server.py", + "--skip-env", + ] + ) + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + assert bound.arguments["skip_env"] is True + + def test_run_command_parsing_skip_source_flag(self): + """Test run command parsing with --skip-source flag.""" + command, bound, _ = app.parse_args( + [ + "run", + "server.py", + "--skip-source", + ] + ) + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + assert bound.arguments["skip_source"] is True + + def test_run_command_parsing_both_skip_flags(self): + """Test run command parsing with both --skip-env and --skip-source flags.""" + command, bound, _ = app.parse_args( + [ + "run", + "server.py", + "--skip-env", + "--skip-source", + ] + ) + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + assert bound.arguments["skip_env"] is True + assert bound.arguments["skip_source"] is True + class TestWindowsSpecific: """Test Windows-specific functionality.""" @@ -557,22 +454,27 @@ class TestWindowsSpecific: def test_windows_path_parsing_with_colon(self, tmp_path): """Test parsing Windows paths with drive letters and colons.""" - from fastmcp.cli.run import parse_file_path + from pathlib import Path + + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + FileSystemSource, + ) # Create a real test file to test the logic test_file = tmp_path / "server.py" test_file.write_text("# test server") # Test normal file parsing (works on all platforms) - file_path, obj = parse_file_path(str(test_file)) - assert obj is None + source = FileSystemSource(path=str(test_file)) + assert source.entrypoint is None + assert Path(source.path).resolve() == test_file.resolve() # Test file:object parsing - file_path, obj = parse_file_path(f"{test_file}:myapp") - assert obj == "myapp" + source = FileSystemSource(path=f"{test_file}:myapp") + assert source.entrypoint == "myapp" # Test that the file portion resolves correctly when object is specified - assert file_path == test_file.resolve() + assert Path(source.path).resolve() == test_file.resolve() class TestInspectCommand: diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 707bc226a..c08c6982b 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -8,77 +8,52 @@ import pytest from pydantic import ValidationError from fastmcp.utilities.fastmcp_config import ( - DeploymentConfig, - EntrypointConfig, - EnvironmentConfig, + Deployment, + Environment, FastMCPConfig, ) +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource -class TestEntrypointConfig: - """Test EntrypointConfig class.""" +class TestFileSystemSource: + """Test FileSystemSource class.""" - def test_string_entrypoint(self): - """Test that string entrypoint is converted to EntrypointConfig.""" - config = FastMCPConfig(entrypoint="server.py") - # With the new validator, this should be converted to EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object is None + def test_dict_source_minimal(self): + """Test that dict source is converted to FileSystemSource.""" + config = FastMCPConfig(source={"path": "server.py"}) + # Dict is converted to FileSystemSource + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint is None + assert config.source.type == "filesystem" - # get_entrypoint should return the same object - entrypoint = config.get_entrypoint() - assert isinstance(entrypoint, EntrypointConfig) - assert entrypoint.file == "server.py" - assert entrypoint.object is None + def test_dict_source_with_entrypoint(self): + """Test dict source with entrypoint field.""" + config = FastMCPConfig(source={"path": "server.py", "entrypoint": "app"}) + # Dict with entrypoint is converted to FileSystemSource + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint == "app" + assert config.source.type == "filesystem" - def test_string_entrypoint_with_object(self): - """Test string entrypoint with :object syntax.""" - config = FastMCPConfig(entrypoint="server.py:app") - # With the new validator, this should be converted to EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object == "app" - - # get_entrypoint should return the same object - entrypoint = config.get_entrypoint() - assert isinstance(entrypoint, EntrypointConfig) - assert entrypoint.file == "server.py" - assert entrypoint.object == "app" - - def test_object_entrypoint(self): - """Test EntrypointConfig object format.""" + def test_filesystem_source_entrypoint(self): + """Test FileSystemSource entrypoint format.""" config = FastMCPConfig( - entrypoint=EntrypointConfig(file="src/server.py", object="mcp") + source=FileSystemSource(path="src/server.py", entrypoint="mcp") ) - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "src/server.py" - assert config.entrypoint.object == "mcp" - - def test_get_entrypoint_path_resolution(self, tmp_path): - """Test that get_entrypoint resolves paths relative to config file.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - server_dir = tmp_path / "src" - server_dir.mkdir() - server_file = server_dir / "server.py" - server_file.write_text("# server") - - config = FastMCPConfig(entrypoint="../src/server.py") - entrypoint = config.get_entrypoint(config_dir / "fastmcp.json") - - # Should resolve to absolute path - assert Path(entrypoint.file).is_absolute() - assert Path(entrypoint.file) == server_file.resolve() + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "src/server.py" + assert config.source.entrypoint == "mcp" + assert config.source.type == "filesystem" -class TestEnvironmentConfig: - """Test EnvironmentConfig class.""" +class TestEnvironment: + """Test Environment class.""" def test_environment_config_fields(self): - """Test all EnvironmentConfig fields.""" + """Test all Environment fields.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["requests", "numpy>=2.0"], @@ -98,27 +73,29 @@ class TestEnvironmentConfig: def test_needs_uv(self): """Test needs_uv() method.""" # No environment config - doesn't need UV - config = FastMCPConfig(entrypoint="server.py") + config = FastMCPConfig(source={"path": "server.py"}) assert not config.environment.needs_uv() # Empty environment - doesn't need UV - config = FastMCPConfig(entrypoint="server.py", environment={}) + config = FastMCPConfig(source={"path": "server.py"}, environment={}) assert not config.environment.needs_uv() # With dependencies - needs UV config = FastMCPConfig( - entrypoint="server.py", environment={"dependencies": ["requests"]} + source={"path": "server.py"}, environment={"dependencies": ["requests"]} ) assert config.environment.needs_uv() # With Python version - needs UV - config = FastMCPConfig(entrypoint="server.py", environment={"python": "3.12"}) + config = FastMCPConfig( + source={"path": "server.py"}, environment={"python": "3.12"} + ) assert config.environment.needs_uv() def test_build_uv_args(self): """Test build_uv_args() method.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["requests", "numpy"], @@ -143,33 +120,10 @@ class TestEnvironmentConfig: assert "run" in args[-2:] assert "server.py" in args[-1:] - def test_merge_with_cli_args(self): - """Test merge_with_cli_args() method.""" - config = FastMCPConfig( - entrypoint="server.py", - environment={ - "python": "3.11", - "dependencies": ["requests"], - }, - ) - - # CLI args should take precedence - merged = config.environment.merge_with_cli_args( - python="3.12", # Override - with_packages=["numpy"], # Add to dependencies - with_requirements=None, - project=None, - ) - - assert merged["python"] == "3.12" # CLI override - assert set(merged["with_packages"]) == {"requests", "numpy"} # Merged - assert merged["with_requirements"] is None - assert merged["project"] is None - def test_run_with_uv(self): """Test run_with_uv() subprocess execution.""" config = FastMCPConfig( - entrypoint="server.py", environment={"dependencies": ["requests"]} + source={"path": "server.py"}, environment={"dependencies": ["requests"]} ) # run_with_uv calls sys.exit, so we expect SystemExit @@ -182,13 +136,13 @@ class TestEnvironmentConfig: assert exc_info.value.code == 1 -class TestDeploymentConfig: - """Test DeploymentConfig class.""" +class TestDeployment: + """Test Deployment class.""" def test_deployment_config_fields(self): - """Test all DeploymentConfig fields.""" + """Test all Deployment fields.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={ "transport": "http", "host": "0.0.0.0", @@ -211,33 +165,6 @@ class TestDeploymentConfig: assert deploy.cwd == "./work" assert deploy.args == ["--debug"] - def test_merge_with_cli_args(self): - """Test DeploymentConfig merge_with_cli_args() method.""" - config = FastMCPConfig( - entrypoint="server.py", - deployment={ - "transport": "stdio", - "port": 3000, - "log_level": "INFO", - }, - ) - - # CLI args should take precedence - merged = config.deployment.merge_with_cli_args( - transport="http", # Override - host="localhost", # New value - port=None, # Keep config value - path=None, - log_level="DEBUG", # Override - server_args=["--test"], - ) - - assert merged["transport"] == "http" # CLI override - assert merged["host"] == "localhost" # CLI value - assert merged["port"] == 3000 # Config value (CLI was None) - assert merged["log_level"] == "DEBUG" # CLI override - assert merged["server_args"] == ["--test"] # CLI value - def test_apply_runtime_settings(self, tmp_path): """Test apply_runtime_settings() method.""" import os @@ -247,7 +174,7 @@ class TestDeploymentConfig: work_dir.mkdir() config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={ "env": {"TEST_VAR": "test_value"}, "cwd": "work", @@ -283,7 +210,7 @@ class TestDeploymentConfig: os.environ["ENV_NAME"] = "production" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={ "env": { "API_URL": "https://api.${BASE_URL}/v1", @@ -328,24 +255,24 @@ class TestFastMCPConfig: def test_minimal_config(self): """Test creating a config with only required fields.""" - config = FastMCPConfig(entrypoint="server.py") - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object is None + config = FastMCPConfig(source={"path": "server.py"}) + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint is None # Environment and deployment are now always present but empty - assert isinstance(config.environment, EnvironmentConfig) - assert isinstance(config.deployment, DeploymentConfig) + assert isinstance(config.environment, Environment) + assert isinstance(config.deployment, Deployment) # Check they have no values set assert not config.environment.needs_uv() assert all( getattr(config.deployment, field, None) is None - for field in DeploymentConfig.model_fields + for field in Deployment.model_fields ) def test_nested_structure(self): """Test the nested configuration structure.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["fastmcp"], @@ -356,17 +283,17 @@ class TestFastMCPConfig: }, ) - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object is None - assert isinstance(config.environment, EnvironmentConfig) - assert isinstance(config.deployment, DeploymentConfig) + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint is None + assert isinstance(config.environment, Environment) + assert isinstance(config.deployment, Deployment) def test_from_file(self, tmp_path): """Test loading config from JSON file with nested structure.""" config_data = { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": {"file": "src/server.py", "object": "app"}, + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": {"path": "src/server.py", "entrypoint": "app"}, "environment": {"python": "3.12", "dependencies": ["requests"]}, "deployment": {"transport": "http", "port": 8000}, } @@ -376,19 +303,19 @@ class TestFastMCPConfig: config = FastMCPConfig.from_file(config_file) - # When loaded from JSON with object format, it becomes EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "src/server.py" - assert config.entrypoint.object == "app" + # When loaded from JSON with entrypoint format, it becomes EntrypointConfig + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "src/server.py" + assert config.source.entrypoint == "app" assert config.environment.python == "3.12" assert config.environment.dependencies == ["requests"] assert config.deployment.transport == "http" assert config.deployment.port == 8000 def test_from_file_with_string_entrypoint(self, tmp_path): - """Test loading config with string entrypoint.""" + """Test loading config with dict source format.""" config_data = { - "entrypoint": "server.py:mcp", + "source": {"path": "server.py", "entrypoint": "mcp"}, "environment": {"dependencies": ["fastmcp"]}, } @@ -397,19 +324,14 @@ class TestFastMCPConfig: config = FastMCPConfig.from_file(config_file) # String entrypoint with : should be converted to EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object == "mcp" + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint == "mcp" - # get_entrypoint should return the same - entrypoint = config.get_entrypoint() - assert entrypoint.file == "server.py" - assert entrypoint.object == "mcp" - - def test_string_entrypoint_with_object_and_environment(self, tmp_path): - """Test that file.py:object syntax works with environment config.""" + def test_string_entrypoint_with_entrypoint_and_environment(self, tmp_path): + """Test that file.py:entrypoint syntax works with environment config.""" config_data = { - "entrypoint": "src/server.py:app", + "source": {"path": "src/server.py", "entrypoint": "app"}, "environment": {"python": "3.12", "dependencies": ["fastmcp", "requests"]}, "deployment": {"transport": "http", "port": 8000}, } @@ -420,9 +342,9 @@ class TestFastMCPConfig: config = FastMCPConfig.from_file(config_file) # Should be parsed into EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "src/server.py" - assert config.entrypoint.object == "app" + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "src/server.py" + assert config.source.entrypoint == "app" # Environment config should still work assert config.environment.python == "3.12" @@ -435,7 +357,7 @@ class TestFastMCPConfig: def test_find_config_in_current_dir(self, tmp_path): """Test finding config in current directory.""" config_file = tmp_path / "fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) original_cwd = os.getcwd() try: @@ -448,7 +370,7 @@ class TestFastMCPConfig: def test_find_config_not_in_parent_dir(self, tmp_path): """Test that config is NOT found in parent directory.""" config_file = tmp_path / "fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) subdir = tmp_path / "subdir" subdir.mkdir() @@ -460,7 +382,7 @@ class TestFastMCPConfig: def test_find_config_in_specified_dir(self, tmp_path): """Test finding config in the specified directory.""" config_file = tmp_path / "fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should find config when looking in the directory that contains it found = FastMCPConfig.find_config(tmp_path) @@ -474,7 +396,7 @@ class TestFastMCPConfig: def test_invalid_transport(self, tmp_path): """Test loading config with invalid transport value.""" config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "deployment": {"transport": "invalid_transport"}, } @@ -485,29 +407,33 @@ class TestFastMCPConfig: FastMCPConfig.from_file(config_file) def test_optional_sections(self): - """Test that all config sections are optional except entrypoint.""" - # Only entrypoint is required - config = FastMCPConfig(entrypoint="server.py") - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" + """Test that all config sections are optional except source.""" + # Only source is required + config = FastMCPConfig(source={"path": "server.py"}) + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" # Environment and deployment are now always present but may be empty - assert isinstance(config.environment, EnvironmentConfig) - assert isinstance(config.deployment, DeploymentConfig) + assert isinstance(config.environment, Environment) + assert isinstance(config.deployment, Deployment) # Only environment with values - config = FastMCPConfig(entrypoint="server.py", environment={"python": "3.12"}) + config = FastMCPConfig( + source={"path": "server.py"}, environment={"python": "3.12"} + ) assert config.environment.python == "3.12" - assert isinstance(config.deployment, DeploymentConfig) + assert isinstance(config.deployment, Deployment) assert all( getattr(config.deployment, field, None) is None - for field in DeploymentConfig.model_fields + for field in Deployment.model_fields ) # Only deployment with values - config = FastMCPConfig(entrypoint="server.py", deployment={"transport": "http"}) - assert isinstance(config.environment, EnvironmentConfig) + config = FastMCPConfig( + source={"path": "server.py"}, deployment={"transport": "http"} + ) + assert isinstance(config.environment, Environment) assert all( getattr(config.environment, field, None) is None - for field in EnvironmentConfig.model_fields + for field in Environment.model_fields ) assert config.deployment.transport == "http" diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index 87145eac8..bd2d39921 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -239,11 +239,14 @@ class TestInstallCursor: """Test cursor installation with editable package.""" mock_open_deeplink.return_value = True + # Use an absolute path that works on all platforms + editable_path = Path.cwd() / "local" / "package" + result = install_cursor( file=Path("/path/to/server.py"), server_object="custom_app", name="test-server", - with_editable=Path("/local/package"), + with_editable=editable_path, ) assert result is True @@ -255,9 +258,10 @@ class TestInstallCursor: config_data = json.loads(decoded) assert "--with-editable" in config_data["args"] - # Check for the editable path in a platform-agnostic way - editable_path_str = str(Path("/local/package")) - assert editable_path_str in config_data["args"] + # Check that the path was resolved (should be absolute) + editable_idx = config_data["args"].index("--with-editable") + 1 + resolved_path = config_data["args"][editable_idx] + assert Path(resolved_path).is_absolute() assert "server.py:custom_app" in " ".join(config_data["args"]) @patch("fastmcp.cli.install.cursor.open_deeplink") @@ -276,7 +280,11 @@ class TestInstallCursor: # Verify failure message was printed mock_print.assert_called() - def test_install_cursor_deduplicate_packages(self): + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, # Mock to disable dev mode so "fastmcp" count is predictable + ) + def test_install_cursor_deduplicate_packages(self, mock_find_dev): """Test that duplicate packages are deduplicated.""" with patch("fastmcp.cli.install.cursor.open_deeplink") as mock_open: mock_open.return_value = True diff --git a/tests/cli/test_fastmcp_config_integration.py b/tests/cli/test_fastmcp_config_integration.py index 01b305d89..98070385f 100644 --- a/tests/cli/test_fastmcp_config_integration.py +++ b/tests/cli/test_fastmcp_config_integration.py @@ -37,8 +37,8 @@ if __name__ == "__main__": # Create config file config_data = { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": "server.py", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": {"path": "server.py"}, "environment": { "python": sys.version.split()[0], # Use current Python version "dependencies": ["fastmcp"], @@ -58,7 +58,7 @@ class TestConfigFileDetection: def test_detect_standard_fastmcp_json(self, tmp_path): """Test detection of standard fastmcp.json file.""" config_file = tmp_path / "fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name @@ -67,7 +67,7 @@ class TestConfigFileDetection: def test_detect_prefixed_fastmcp_json(self, tmp_path): """Test detection of prefixed fastmcp.json files.""" config_file = tmp_path / "my.fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name @@ -75,7 +75,7 @@ class TestConfigFileDetection: def test_detect_test_fastmcp_json(self, tmp_path): """Test detection of test_fastmcp.json file.""" config_file = tmp_path / "test_fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name @@ -91,14 +91,18 @@ class TestConfigWithClient: config_file = server_with_config / "fastmcp.json" config = FastMCPConfig.from_file(config_file) - # Import the server using the entrypoint + # Import the server using the source import importlib.util import sys - entrypoint = config.get_entrypoint(config_file) - spec = importlib.util.spec_from_file_location("test_server", entrypoint.file) + # Resolve the path from the source + source_path = Path(config.source.path) + if not source_path.is_absolute(): + source_path = (config_file.parent / source_path).resolve() + + spec = importlib.util.spec_from_file_location("test_server", str(source_path)) if spec is None or spec.loader is None: - raise RuntimeError(f"Could not load module from {entrypoint.file}") + raise RuntimeError(f"Could not load module from {source_path}") module = importlib.util.module_from_spec(spec) sys.modules["test_server"] = module spec.loader.exec_module(module) @@ -129,7 +133,7 @@ class TestEnvironmentExecution: def test_needs_uv_with_dependencies(self): """Test that environment with dependencies needs UV.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={"dependencies": ["requests", "numpy"]}, # type: ignore[arg-type] ) @@ -139,7 +143,7 @@ class TestEnvironmentExecution: def test_needs_uv_with_python_version(self): """Test that environment with Python version needs UV.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={"python": "3.12"}, # type: ignore[arg-type] ) @@ -148,7 +152,7 @@ class TestEnvironmentExecution: def test_no_uv_needed_without_environment(self): """Test that no UV is needed without environment config.""" - config = FastMCPConfig(entrypoint="server.py") + config = FastMCPConfig(source={"path": "server.py"}) # Environment is now always present but may be empty assert config.environment is not None @@ -157,7 +161,7 @@ class TestEnvironmentExecution: def test_no_uv_needed_with_empty_environment(self): """Test that no UV is needed with empty environment config.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={}, # type: ignore[arg-type] ) @@ -165,77 +169,11 @@ class TestEnvironmentExecution: assert not config.environment.needs_uv() -class TestCLIArgumentMerging: - """Test CLI argument merging with config values.""" - - def test_cli_overrides_environment(self): - """Test that CLI args override environment config.""" - config = FastMCPConfig( - entrypoint="server.py", - environment={"python": "3.11", "dependencies": ["requests"]}, # type: ignore[arg-type] - ) - - assert config.environment is not None - merged = config.environment.merge_with_cli_args( - python="3.12", # Override Python version - with_packages=["numpy"], # Add package - with_requirements=None, - project=None, - ) - - assert merged["python"] == "3.12" # CLI wins - assert "requests" in merged["with_packages"] # From config - assert "numpy" in merged["with_packages"] # From CLI - - def test_cli_overrides_deployment(self): - """Test that CLI args override deployment config.""" - config = FastMCPConfig( - entrypoint="server.py", - deployment={"transport": "stdio", "port": 3000, "log_level": "INFO"}, # type: ignore[arg-type] - ) - - assert config.deployment is not None - merged = config.deployment.merge_with_cli_args( - transport="http", # Override transport - host="localhost", # New value - port=8080, # Override port - path=None, - log_level="DEBUG", # Override log level - server_args=None, - ) - - assert merged["transport"] == "http" # CLI wins - assert merged["host"] == "localhost" # CLI value - assert merged["port"] == 8080 # CLI wins - assert merged["log_level"] == "DEBUG" # CLI wins - - def test_config_values_when_cli_is_none(self): - """Test that config values are used when CLI args are None.""" - config = FastMCPConfig( - entrypoint="server.py", - deployment={"transport": "http", "port": 3000}, # type: ignore[arg-type] - ) - - assert config.deployment is not None - merged = config.deployment.merge_with_cli_args( - transport=None, # Use config - host=None, # No value - port=None, # Use config - path=None, - log_level=None, - server_args=None, - ) - - assert merged["transport"] == "http" # From config - assert merged["port"] == 3000 # From config - assert merged["host"] is None # No value provided - - class TestPathResolution: """Test path resolution in configurations.""" - def test_entrypoint_path_resolution(self, tmp_path): - """Test that entrypoint paths are resolved relative to config.""" + def test_source_path_resolution(self, tmp_path): + """Test that source paths are resolved relative to config.""" # Create nested directory structure config_dir = tmp_path / "config" config_dir.mkdir() @@ -246,14 +184,11 @@ class TestPathResolution: server_file = src_dir / "server.py" server_file.write_text("# Server") - config = FastMCPConfig(entrypoint="../src/server.py") + config = FastMCPConfig(source={"path": "../src/server.py"}) - # Get entrypoint resolved relative to config location - config_file = config_dir / "fastmcp.json" - entrypoint = config.get_entrypoint(config_file) - - # Should resolve to absolute path of server file - assert Path(entrypoint.file) == server_file.resolve() + # The source path is resolved during load_server + # For now, just check that the source is created correctly + assert config.source.path == "../src/server.py" def test_cwd_path_resolution(self, tmp_path): """Test that working directory is resolved relative to config.""" @@ -264,7 +199,7 @@ class TestPathResolution: work_dir.mkdir() config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"cwd": "work"}, # type: ignore[arg-type] ) @@ -288,7 +223,7 @@ class TestPathResolution: reqs_file.write_text("fastmcp>=2.0") config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={"requirements": "requirements.txt"}, # type: ignore[arg-type] ) @@ -309,7 +244,7 @@ class TestConfigValidation: """Test that invalid transport values are rejected.""" with pytest.raises(ValueError): FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"transport": "invalid_transport"}, # type: ignore[arg-type] ) @@ -317,7 +252,7 @@ class TestConfigValidation: """Test that streamable-http transport is rejected in fastmcp.json config.""" with pytest.raises(ValueError): FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"transport": "streamable-http"}, # type: ignore[arg-type] ) @@ -325,12 +260,12 @@ class TestConfigValidation: """Test that invalid log level values are rejected.""" with pytest.raises(ValueError): FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"log_level": "INVALID"}, # type: ignore[arg-type] ) - def test_missing_entrypoint_rejected(self): - """Test that config without entrypoint is rejected.""" + def test_missing_source_rejected(self): + """Test that config without source is rejected.""" with pytest.raises(ValueError): FastMCPConfig() # type: ignore[call-arg] @@ -338,7 +273,7 @@ class TestConfigValidation: """Test that all valid transport values are accepted.""" for transport in ["stdio", "http", "sse"]: config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"transport": transport}, # type: ignore[arg-type] ) assert config.deployment is not None @@ -348,7 +283,7 @@ class TestConfigValidation: """Test that all valid log levels are accepted.""" for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"log_level": level}, # type: ignore[arg-type] ) assert config.deployment is not None diff --git a/tests/cli/test_fastmcp_config_schema.py b/tests/cli/test_fastmcp_config_schema.py index f2ac44672..5c05cd65f 100644 --- a/tests/cli/test_fastmcp_config_schema.py +++ b/tests/cli/test_fastmcp_config_schema.py @@ -40,10 +40,11 @@ def test_schema_has_correct_id(): """Test that the schema has the correct $id field.""" generated_schema = generate_schema() + assert generated_schema is not None assert "$id" in generated_schema assert ( generated_schema["$id"] - == "https://gofastmcp.com/schemas/fastmcp_config/v1.json" + == "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" ) @@ -51,19 +52,21 @@ def test_schema_has_required_fields(): """Test that the schema specifies the required fields correctly.""" generated_schema = generate_schema() - # Check that entrypoint is required + assert generated_schema is not None + # Check that source is required assert "required" in generated_schema - assert "entrypoint" in generated_schema["required"] + assert "source" in generated_schema["required"] - # Check that entrypoint is in properties + # Check that source is in properties assert "properties" in generated_schema - assert "entrypoint" in generated_schema["properties"] + assert "source" in generated_schema["properties"] def test_schema_nested_structure(): """Test that the schema has the correct nested structure.""" generated_schema = generate_schema() + assert generated_schema is not None properties = generated_schema["properties"] # Check environment section @@ -95,6 +98,7 @@ def test_schema_transport_enum(): """Test that transport field has correct enum values.""" generated_schema = generate_schema() + assert generated_schema is not None # Navigate to transport field deploy_schema = generated_schema["properties"]["deployment"] @@ -129,6 +133,7 @@ def test_schema_log_level_enum(): """Test that log_level field has correct enum values.""" generated_schema = generate_schema() + assert generated_schema is not None # Navigate to log_level field deploy_schema = generated_schema["properties"]["deployment"] diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index d6d2578ce..f5375f7e0 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -7,14 +7,13 @@ from pydantic import ValidationError from fastmcp.cli.run import ( create_mcp_config_server, - import_server, is_url, - parse_file_path, ) from fastmcp.client.client import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.mcp_config import MCPConfig, StdioMCPServer from fastmcp.server.server import FastMCP +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource class TestUrlDetection: @@ -41,52 +40,54 @@ class TestUrlDetection: assert not is_url("file:///path/to/file") -class TestFilePathParsing: - """Test file path parsing functionality.""" +class TestFileSystemSource: + """Test FileSystemSource path parsing functionality.""" - def test_parse_file_path_simple(self, tmp_path): + def test_parse_simple_path(self, tmp_path): """Test parsing simple file path without object.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") - file_path, server_object = parse_file_path(str(test_file)) - assert file_path == test_file.resolve() - assert server_object is None + source = FileSystemSource(path=str(test_file)) + assert Path(source.path).resolve() == test_file.resolve() + assert source.entrypoint is None - def test_parse_file_path_with_object(self, tmp_path): + def test_parse_path_with_object(self, tmp_path): """Test parsing file path with object specification.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") - file_path, server_object = parse_file_path(f"{test_file}:app") - assert file_path == test_file.resolve() - assert server_object == "app" + source = FileSystemSource(path=f"{test_file}:app") + assert Path(source.path).resolve() == test_file.resolve() + assert source.entrypoint == "app" - def test_parse_file_path_complex_object(self, tmp_path): + def test_parse_complex_object(self, tmp_path): """Test parsing file path with complex object specification.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") - # The current implementation splits on the last colon, so file:module:app - # becomes file_path="file:module" and server_object="app" + # The implementation splits on the last colon, so file:module:app + # becomes file_path="file:module" and entrypoint="app" # We need to create a file with a colon in the name for this test complex_file = tmp_path / "server:module.py" complex_file.write_text("# test server") - file_path, server_object = parse_file_path(f"{complex_file}:app") - assert file_path == complex_file.resolve() - assert server_object == "app" + source = FileSystemSource(path=f"{complex_file}:app") + assert Path(source.path).resolve() == complex_file.resolve() + assert source.entrypoint == "app" - def test_parse_file_path_nonexistent(self): - """Test parsing nonexistent file path exits.""" + async def test_load_server_nonexistent(self): + """Test loading nonexistent file path exits.""" + source = FileSystemSource(path="nonexistent.py") with pytest.raises(SystemExit) as exc_info: - parse_file_path("nonexistent.py") + await source.load_server() assert exc_info.value.code == 1 - def test_parse_file_path_directory(self, tmp_path): - """Test parsing directory path exits.""" + async def test_load_server_directory(self, tmp_path): + """Test loading directory path exits.""" + source = FileSystemSource(path=str(tmp_path)) with pytest.raises(SystemExit) as exc_info: - parse_file_path(str(tmp_path)) + await source.load_server() assert exc_info.value.code == 1 @@ -157,7 +158,8 @@ def greet(name: str) -> str: return f"Hello, {name}!" """) - server = await import_server(test_file) + source = FileSystemSource(path=str(test_file)) + server = await source.load_server() assert server.name == "TestServer" tools = await server.get_tools() assert "greet" in tools @@ -178,7 +180,8 @@ if __name__ == "__main__": app.run() """) - server = await import_server(test_file) + source = FileSystemSource(path=str(test_file)) + server = await source.load_server() assert server.name == "MainServer" tools = await server.get_tools() assert "calculate" in tools @@ -192,7 +195,8 @@ import fastmcp mcp = fastmcp.FastMCP("MCPServer") """) - server = await import_server(mcp_file) + source = FileSystemSource(path=str(mcp_file)) + server = await source.load_server() assert server.name == "MCPServer" # Test with 'server' name @@ -202,7 +206,8 @@ import fastmcp server = fastmcp.FastMCP("ServerServer") """) - server = await import_server(server_file) + source = FileSystemSource(path=str(server_file)) + server = await source.load_server() assert server.name == "ServerServer" # Test with 'app' name @@ -212,7 +217,8 @@ import fastmcp app = fastmcp.FastMCP("AppServer") """) - server = await import_server(app_file) + source = FileSystemSource(path=str(app_file)) + server = await source.load_server() assert server.name == "AppServer" async def test_import_server_nonstandard_name(self, tmp_path): @@ -228,7 +234,8 @@ def custom_tool() -> str: return "custom" """) - server = await import_server(test_file, "my_custom_server") + source = FileSystemSource(path=f"{test_file}:my_custom_server") + server = await source.load_server() assert server.name == "CustomServer" tools = await server.get_tools() assert "custom_tool" in tools @@ -242,8 +249,9 @@ import fastmcp other_name = fastmcp.FastMCP("OtherServer") """) + source = FileSystemSource(path=str(test_file)) with pytest.raises(SystemExit) as exc_info: - await import_server(test_file) + await source.load_server() assert exc_info.value.code == 1 async def test_import_server_nonexistent_object_fails(self, tmp_path): @@ -255,6 +263,131 @@ import fastmcp mcp = fastmcp.FastMCP("TestServer") """) + source = FileSystemSource(path=f"{test_file}:nonexistent") with pytest.raises(SystemExit) as exc_info: - await import_server(test_file, "nonexistent") + await source.load_server() assert exc_info.value.code == 1 + + +class TestSkipSource: + """Test the --skip-source functionality.""" + + async def test_run_command_calls_prepare_by_default(self, tmp_path): + """Test that run_command calls source.prepare() by default.""" + from unittest.mock import AsyncMock, patch + + from fastmcp.cli.run import run_command + + # Create a test server file + test_file = tmp_path / "server.py" + test_file.write_text(""" +import fastmcp +mcp = fastmcp.FastMCP("TestServer") +""") + + # Create a test config file + config_file = tmp_path / "fastmcp.json" + config_data = {"source": {"path": str(test_file), "entrypoint": "mcp"}} + config_file.write_text(json.dumps(config_data)) + + # Mock the prepare method and server run + with ( + patch.object( + FileSystemSource, "prepare", new_callable=AsyncMock + ) as prepare_mock, + patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), + ): + # Run the command + await run_command(str(config_file)) + + # Verify prepare was called + prepare_mock.assert_called_once() + + async def test_run_command_skips_prepare_with_flag(self, tmp_path): + """Test that run_command skips source.prepare() when skip_source=True.""" + from unittest.mock import AsyncMock, patch + + from fastmcp.cli.run import run_command + + # Create a test server file + test_file = tmp_path / "server.py" + test_file.write_text(""" +import fastmcp +mcp = fastmcp.FastMCP("TestServer") +""") + + # Create a test config file + config_file = tmp_path / "fastmcp.json" + config_data = {"source": {"path": str(test_file), "entrypoint": "mcp"}} + config_file.write_text(json.dumps(config_data)) + + # Mock the prepare method and server run + with ( + patch.object( + FileSystemSource, "prepare", new_callable=AsyncMock + ) as prepare_mock, + patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), + ): + # Run the command with skip_source=True + await run_command(str(config_file), skip_source=True) + + # Verify prepare was NOT called + prepare_mock.assert_not_called() + + async def test_filesystem_source_prepare_by_default(self, tmp_path): + """Test that FileSystemSource is prepared when using direct file spec.""" + from unittest.mock import AsyncMock, patch + + from fastmcp.cli.run import run_command + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + FileSystemSource, + ) + + # Create a test server file + test_file = tmp_path / "server.py" + test_file.write_text(""" +import fastmcp +mcp = fastmcp.FastMCP("TestServer") +""") + + # Mock the prepare method and server run + with ( + patch.object( + FileSystemSource, "prepare", new_callable=AsyncMock + ) as prepare_mock, + patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), + ): + # Run with direct file specification + await run_command(str(test_file)) + + # Verify prepare was called + prepare_mock.assert_called_once() + + async def test_filesystem_source_skip_prepare_with_flag(self, tmp_path): + """Test that FileSystemSource.prepare() is skipped with skip_source flag.""" + from unittest.mock import AsyncMock, patch + + from fastmcp.cli.run import run_command + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + FileSystemSource, + ) + + # Create a test server file + test_file = tmp_path / "server.py" + test_file.write_text(""" +import fastmcp +mcp = fastmcp.FastMCP("TestServer") +""") + + # Mock the prepare method and server run + with ( + patch.object( + FileSystemSource, "prepare", new_callable=AsyncMock + ) as prepare_mock, + patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), + ): + # Run with direct file specification and skip_source=True + await run_command(str(test_file), skip_source=True) + + # Verify prepare was NOT called + prepare_mock.assert_not_called() diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py index f0e444c8a..a4320762e 100644 --- a/tests/cli/test_run_config.py +++ b/tests/cli/test_run_config.py @@ -8,18 +8,19 @@ import pytest from fastmcp.cli.run import load_fastmcp_config from fastmcp.utilities.fastmcp_config import ( - DeploymentConfig, - EntrypointConfig, - EnvironmentConfig, + Deployment, + Environment, + FastMCPConfig, ) +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource @pytest.fixture def sample_config(tmp_path): """Create a sample fastmcp.json configuration file with nested structure.""" config_data = { - "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "entrypoint": "server.py", + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": {"path": "server.py"}, "environment": {"python": "3.11", "dependencies": ["requests"]}, "deployment": {"transport": "stdio", "env": {"TEST_VAR": "test_value"}}, } @@ -49,25 +50,25 @@ def test_load_fastmcp_config(sample_config, monkeypatch): original_env = dict(os.environ) try: - entrypoint, deployment, environment = load_fastmcp_config(sample_config) + config = load_fastmcp_config(sample_config) # Check that we got the right types - assert isinstance(entrypoint, EntrypointConfig) - assert isinstance(deployment, DeploymentConfig) - assert isinstance(environment, EnvironmentConfig) + assert isinstance(config, FastMCPConfig) + assert isinstance(config.source, FileSystemSource) + assert isinstance(config.deployment, Deployment) + assert isinstance(config.environment, Environment) - # Check entrypoint - assert entrypoint.file.endswith("server.py") - assert Path(entrypoint.file).is_absolute() - assert entrypoint.object is None + # Check source - path is not resolved yet, only during load_server + assert config.source.path == "server.py" + assert config.source.entrypoint is None # Check environment config - assert environment.python == "3.11" - assert environment.dependencies == ["requests"] + assert config.environment.python == "3.11" + assert config.environment.dependencies == ["requests"] # Check deployment config - assert deployment.transport == "stdio" - assert deployment.env == {"TEST_VAR": "test_value"} + assert config.deployment.transport == "stdio" + assert config.deployment.env == {"TEST_VAR": "test_value"} # Check that environment variables were applied assert os.environ.get("TEST_VAR") == "test_value" @@ -78,10 +79,10 @@ def test_load_fastmcp_config(sample_config, monkeypatch): os.environ.update(original_env) -def test_load_config_with_object_entrypoint(tmp_path): - """Test loading config with object-format entrypoint.""" +def test_load_config_with_entrypoint_source(tmp_path): + """Test loading config with entrypoint-format source.""" config_data = { - "entrypoint": {"file": "src/server.py", "object": "app"}, + "source": {"path": "src/server.py", "entrypoint": "app"}, "deployment": {"transport": "http", "port": 8000}, } @@ -94,29 +95,25 @@ def test_load_config_with_object_entrypoint(tmp_path): server_file = src_dir / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) - # Check entrypoint resolution - assert entrypoint.file == str(server_file.resolve()) - assert entrypoint.object == "app" + # Check source - path is not resolved yet, only during load_server + assert config.source.path == "src/server.py" + assert config.source.entrypoint == "app" # Check deployment - assert deployment is not None - assert deployment.transport == "http" - assert deployment.port == 8000 - - # No environment config - assert environment is None + assert config.deployment.transport == "http" + assert config.deployment.port == 8000 def test_load_config_with_cwd(tmp_path): - """Test that DeploymentConfig applies working directory change.""" + """Test that Deployment applies working directory change.""" # Create a subdirectory subdir = tmp_path / "subdir" subdir.mkdir() - config_data = {"entrypoint": "server.py", "deployment": {"cwd": "subdir"}} + config_data = {"source": {"path": "server.py"}, "deployment": {"cwd": "subdir"}} config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) @@ -128,7 +125,7 @@ def test_load_config_with_cwd(tmp_path): original_cwd = os.getcwd() try: - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) # noqa: F841 # Check that working directory was changed assert Path.cwd() == subdir.resolve() @@ -147,7 +144,7 @@ def test_load_config_with_relative_cwd(tmp_path): subdir2.mkdir(parents=True) config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "deployment": { "cwd": "../" # Relative to config file location }, @@ -163,7 +160,7 @@ def test_load_config_with_relative_cwd(tmp_path): original_cwd = os.getcwd() try: - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) # noqa: F841 # Should change to parent directory of config file assert Path.cwd() == subdir1.resolve() @@ -173,8 +170,8 @@ def test_load_config_with_relative_cwd(tmp_path): def test_load_minimal_config(tmp_path): - """Test loading minimal configuration with only entrypoint.""" - config_data = {"entrypoint": "server.py"} + """Test loading minimal configuration with only source.""" + config_data = {"source": {"path": "server.py"}} config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) @@ -183,21 +180,17 @@ def test_load_minimal_config(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) - # Check we got entrypoint - assert isinstance(entrypoint, EntrypointConfig) - assert entrypoint.file == str(server_file.resolve()) - - # No deployment or environment - assert deployment is None - assert environment is None + # Check we got source - path is not resolved yet, only during load_server + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" def test_load_config_with_server_args(tmp_path): """Test configuration with server arguments.""" config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "deployment": {"args": ["--debug", "--config", "custom.json"]}, } @@ -208,16 +201,15 @@ def test_load_config_with_server_args(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) - assert deployment is not None - assert deployment.args == ["--debug", "--config", "custom.json"] + assert config.deployment.args == ["--debug", "--config", "custom.json"] def test_config_subset_independence(tmp_path): """Test that config subsets can be used independently.""" config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "environment": {"python": "3.12", "dependencies": ["pandas"]}, "deployment": {"transport": "http", "host": "0.0.0.0", "port": 3000}, } @@ -229,36 +221,20 @@ def test_config_subset_independence(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) # Each subset should be independently usable - assert entrypoint.file == str(server_file.resolve()) - assert entrypoint.object is None + # Path is not resolved yet, only during load_server + assert config.source.path == "server.py" + assert config.source.entrypoint is None - assert environment is not None - assert environment.python == "3.12" - assert environment.dependencies == ["pandas"] - assert environment.needs_uv() # Has dependencies + assert config.environment.python == "3.12" + assert config.environment.dependencies == ["pandas"] + assert config.environment.needs_uv() # Has dependencies - assert deployment is not None - assert deployment.transport == "http" - assert deployment.host == "0.0.0.0" - assert deployment.port == 3000 - - # Can merge deployment config with CLI args - merged = deployment.merge_with_cli_args( - transport=None, # Keep config value - host="localhost", # Override - port=8080, # Override - path="/api", # New value - log_level=None, - server_args=None, - ) - - assert merged["transport"] == "http" # Kept from config - assert merged["host"] == "localhost" # CLI override - assert merged["port"] == 8080 # CLI override - assert merged["path"] == "/api" # CLI value + assert config.deployment.transport == "http" + assert config.deployment.host == "0.0.0.0" + assert config.deployment.port == 3000 def test_environment_config_path_resolution(tmp_path): @@ -268,7 +244,7 @@ def test_environment_config_path_resolution(tmp_path): reqs_file.write_text("fastmcp>=2.0") config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "environment": { "requirements": "requirements.txt", "project": ".", @@ -283,11 +259,10 @@ def test_environment_config_path_resolution(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) # Check that UV args are built with resolved paths - assert environment is not None - uv_args = environment.build_uv_args(["fastmcp", "run", "server.py"]) + uv_args = config.environment.build_uv_args(["fastmcp", "run", "server.py"]) assert "--with-requirements" in uv_args assert "--project" in uv_args diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py index 3aac81fa7..6007b3d33 100644 --- a/tests/cli/test_run_with_uv.py +++ b/tests/cli/test_run_with_uv.py @@ -12,8 +12,12 @@ from fastmcp.cli.run import run_with_uv class TestRunWithUv: """Test the run_with_uv function.""" + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_basic(self, mock_run): + def test_run_with_uv_basic(self, mock_run, mock_find_dev_path): """Test basic run_with_uv execution.""" mock_run.return_value = Mock(returncode=0) @@ -26,11 +30,24 @@ class TestRunWithUv: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] - expected = ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "server.py"] + expected = [ + "uv", + "run", + "--with", + "fastmcp", + "fastmcp", + "run", + "server.py", + "--skip-env", + ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_python_version(self, mock_run): + def test_run_with_uv_python_version(self, mock_run, mock_find_dev_path): """Test run_with_uv with Python version.""" mock_run.return_value = Mock(returncode=0) @@ -50,14 +67,20 @@ class TestRunWithUv: "fastmcp", "run", "server.py", + "--skip-env", ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_project(self, mock_run): + def test_run_with_uv_project(self, mock_run, mock_find_dev_path): """Test run_with_uv with project directory.""" mock_run.return_value = Mock(returncode=0) - project_path = Path("/my/project") + # Use an absolute path that works on all platforms + project_path = Path.cwd() / "my" / "project" with pytest.raises(SystemExit) as exc_info: run_with_uv("server.py", project=project_path) @@ -65,21 +88,26 @@ class TestRunWithUv: assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] - expected = [ - "uv", - "run", - "--project", - str(Path("/my/project")), + # Check the basic structure + assert cmd[:3] == ["uv", "run", "--project"] + # Check that the project path is absolute + assert Path(cmd[3]).is_absolute() + # Check the rest of the command + assert cmd[4:] == [ "--with", "fastmcp", "fastmcp", "run", "server.py", + "--skip-env", ] - assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_with_packages(self, mock_run): + def test_run_with_uv_with_packages(self, mock_run, mock_find_dev_path): """Test run_with_uv with additional packages.""" mock_run.return_value = Mock(returncode=0) @@ -95,17 +123,22 @@ class TestRunWithUv: "--with", "fastmcp", "--with", - "pandas", + "pandas", # original order preserved "--with", - "numpy", + "numpy", # original order preserved "fastmcp", "run", "server.py", + "--skip-env", ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_with_requirements(self, mock_run): + def test_run_with_uv_with_requirements(self, mock_run, mock_find_dev_path): """Test run_with_uv with requirements file.""" mock_run.return_value = Mock(returncode=0) req_path = Path("requirements.txt") @@ -122,15 +155,20 @@ class TestRunWithUv: "--with", "fastmcp", "--with-requirements", - "requirements.txt", + str(req_path.resolve()), # auto-resolved to absolute path "fastmcp", "run", "server.py", + "--skip-env", ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_transport_options(self, mock_run): + def test_run_with_uv_transport_options(self, mock_run, mock_find_dev_path): """Test run_with_uv with transport-related options.""" mock_run.return_value = Mock(returncode=0) @@ -156,6 +194,7 @@ class TestRunWithUv: "fastmcp", "run", "server.py", + "--skip-env", "--transport", "http", "--host", @@ -170,16 +209,23 @@ class TestRunWithUv: ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_all_options(self, mock_run): + def test_run_with_uv_all_options(self, mock_run, mock_find_dev_path): """Test run_with_uv with all options combined.""" mock_run.return_value = Mock(returncode=0) + # Use an absolute path that works on all platforms + project_path = Path.cwd() / "workspace" + with pytest.raises(SystemExit) as exc_info: run_with_uv( "server.py", python_version="3.10", - project=Path("/workspace"), + project=project_path, with_packages=["pandas"], with_requirements=Path("reqs.txt"), transport="http", @@ -190,32 +236,34 @@ class TestRunWithUv: assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] - expected = [ - "uv", - "run", - "--python", - "3.10", - "--project", - str(Path("/workspace")), - "--with", - "fastmcp", - "--with", - "pandas", - "--with-requirements", - "reqs.txt", + + # Check the structure piece by piece to be platform-agnostic + assert cmd[:5] == ["uv", "run", "--python", "3.10", "--project"] + # Check project path is absolute + assert Path(cmd[5]).is_absolute() + assert cmd[6:10] == ["--with", "fastmcp", "--with", "pandas"] + assert cmd[10] == "--with-requirements" + # Check requirements path is now auto-resolved to absolute + assert Path(cmd[11]).is_absolute() + assert Path(cmd[11]).name == "reqs.txt" + assert cmd[12:] == [ "fastmcp", "run", "server.py", + "--skip-env", "--transport", "http", "--port", "9000", "--no-banner", ] - assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_error_handling(self, mock_run): + def test_run_with_uv_error_handling(self, mock_run, mock_find_dev_path): """Test run_with_uv error handling.""" mock_run.side_effect = subprocess.CalledProcessError(1, ["uv", "run"]) @@ -224,9 +272,13 @@ class TestRunWithUv: assert exc_info.value.code == 1 + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("fastmcp.cli.run.logger") @patch("subprocess.run") - def test_run_with_uv_logging(self, mock_run, mock_logger): + def test_run_with_uv_logging(self, mock_run, mock_logger, mock_find_dev_path): """Test that run_with_uv logs the command.""" mock_run.return_value = Mock(returncode=0) diff --git a/tests/cli/test_server_args.py b/tests/cli/test_server_args.py new file mode 100644 index 000000000..5ec7ccc6a --- /dev/null +++ b/tests/cli/test_server_args.py @@ -0,0 +1,139 @@ +"""Test server argument passing functionality.""" + +from pathlib import Path + +import pytest + +from fastmcp.utilities.fastmcp_config import FastMCPConfig +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource + + +class TestServerArguments: + """Test passing arguments to servers.""" + + @pytest.mark.asyncio + async def test_server_with_argparse(self, tmp_path): + """Test a server that uses argparse with command line arguments.""" + server_file = tmp_path / "argparse_server.py" + server_file.write_text(""" +import argparse +from fastmcp import FastMCP + +parser = argparse.ArgumentParser() +parser.add_argument("--name", default="DefaultServer") +parser.add_argument("--port", type=int, default=8000) +parser.add_argument("--debug", action="store_true") + +args = parser.parse_args() + +server_name = f"{args.name}:{args.port}" +if args.debug: + server_name += " (Debug)" + +mcp = FastMCP(server_name) + +@mcp.tool +def get_config() -> dict: + return {"name": args.name, "port": args.port, "debug": args.debug} +""") + + # Test with arguments + source = FileSystemSource(path=str(server_file)) + config = FastMCPConfig(source=source) + + from fastmcp.cli.cli import with_argv + + # Simulate passing arguments + with with_argv(["--name", "TestServer", "--port", "9000", "--debug"]): + server = await config.source.load_server() + + assert server.name == "TestServer:9000 (Debug)" + + # Test the tool works and can access the parsed args + tools = await server.get_tools() + assert "get_config" in tools + + @pytest.mark.asyncio + async def test_server_with_no_args(self, tmp_path): + """Test a server that uses argparse with no arguments (defaults).""" + server_file = tmp_path / "default_server.py" + server_file.write_text(""" +import argparse +from fastmcp import FastMCP + +parser = argparse.ArgumentParser() +parser.add_argument("--name", default="DefaultName") +args = parser.parse_args() + +mcp = FastMCP(args.name) +""") + + source = FileSystemSource(path=str(server_file)) + config = FastMCPConfig(source=source) + + from fastmcp.cli.cli import with_argv + + # Test with empty args list (should use defaults) + with with_argv([]): + server = await config.source.load_server() + + assert server.name == "DefaultName" + + @pytest.mark.asyncio + async def test_server_with_sys_argv_access(self, tmp_path): + """Test a server that directly accesses sys.argv.""" + server_file = tmp_path / "sysargv_server.py" + server_file.write_text(""" +import sys +from fastmcp import FastMCP + +# Direct sys.argv access (less common but should work) +name = "DirectServer" +if len(sys.argv) > 1 and sys.argv[1] == "--custom": + name = "CustomServer" + +mcp = FastMCP(name) +""") + + source = FileSystemSource(path=str(server_file)) + config = FastMCPConfig(source=source) + + from fastmcp.cli.cli import with_argv + + # Test with custom argument + with with_argv(["--custom"]): + server = await config.source.load_server() + + assert server.name == "CustomServer" + + # Test without argument + with with_argv([]): + server = await config.source.load_server() + + assert server.name == "DirectServer" + + @pytest.mark.asyncio + async def test_config_server_example(self): + """Test the actual config_server.py example.""" + # Find the examples directory + examples_dir = Path(__file__).parent.parent.parent / "examples" + config_server = examples_dir / "config_server.py" + + if not config_server.exists(): + pytest.skip("config_server.py example not found") + + source = FileSystemSource(path=str(config_server)) + config = FastMCPConfig(source=source) + + from fastmcp.cli.cli import with_argv + + # Test with debug flag + with with_argv(["--name", "TestExample", "--debug"]): + server = await config.source.load_server() + + assert server.name == "TestExample (Debug)" + + # Verify tools are available + tools = await server.get_tools() + assert "get_status" in tools + assert "echo_message" in tools diff --git a/tests/cli/test_with_argv.py b/tests/cli/test_with_argv.py new file mode 100644 index 000000000..ee9d378d4 --- /dev/null +++ b/tests/cli/test_with_argv.py @@ -0,0 +1,91 @@ +"""Test the with_argv context manager.""" + +import sys +from unittest.mock import patch + +import pytest + +from fastmcp.cli.cli import with_argv + + +class TestWithArgv: + """Test the with_argv context manager.""" + + def test_with_argv_replaces_args(self): + """Test that with_argv properly replaces sys.argv.""" + original_argv = sys.argv[:] + test_args = ["--name", "TestServer", "--debug"] + + with with_argv(test_args): + # Should preserve script name and add new args + assert sys.argv[0] == original_argv[0] + assert sys.argv[1:] == test_args + + # Should restore original argv after context + assert sys.argv == original_argv + + def test_with_argv_none_does_nothing(self): + """Test that with_argv(None) doesn't change sys.argv.""" + original_argv = sys.argv[:] + + with with_argv(None): + assert sys.argv == original_argv + + assert sys.argv == original_argv + + def test_with_argv_empty_list(self): + """Test that with_argv([]) clears arguments but keeps script name.""" + original_argv = sys.argv[:] + + with with_argv([]): + # Should have only the script name (no additional args) + assert sys.argv == [original_argv[0]] + assert len(sys.argv) == 1 + + assert sys.argv == original_argv + + def test_with_argv_restores_on_exception(self): + """Test that sys.argv is restored even if an exception occurs.""" + original_argv = sys.argv[:] + test_args = ["--error"] + + with pytest.raises(ValueError): + with with_argv(test_args): + assert sys.argv == [original_argv[0]] + test_args + raise ValueError("Test error") + + # Should still restore original argv + assert sys.argv == original_argv + + def test_with_argv_nested(self): + """Test nested with_argv contexts.""" + original_argv = sys.argv[:] + args1 = ["--level1"] + args2 = ["--level2", "--debug"] + + with with_argv(args1): + assert sys.argv == [original_argv[0]] + args1 + + with with_argv(args2): + assert sys.argv == [original_argv[0]] + args2 + + # Should restore to level 1 + assert sys.argv == [original_argv[0]] + args1 + + # Should restore to original + assert sys.argv == original_argv + + @patch("sys.argv", ["test_script.py", "existing", "args"]) + def test_with_argv_with_existing_args(self): + """Test with_argv when sys.argv already has arguments.""" + original_argv = sys.argv[:] + assert original_argv == ["test_script.py", "existing", "args"] + + test_args = ["--new", "args"] + + with with_argv(test_args): + # Should replace existing args but keep script name + assert sys.argv == ["test_script.py", "--new", "args"] + + # Should restore original + assert sys.argv == original_argv diff --git a/tests/conftest.py b/tests/conftest.py index 4fba247c6..d82413029 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,3 +7,11 @@ def pytest_collection_modifyitems(items): # Check if the test is in the integration_tests folder if "integration_tests" in str(item.fspath): item.add_marker(pytest.mark.integration) + + +@pytest.fixture(autouse=True) +def import_rich_rule(): + # What a hack + import rich.rule # noqa: F401 + + yield diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 88cb5d363..b19b080b2 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -11,11 +11,77 @@ from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RS from fastmcp.utilities.tests import run_server_in_process +class SymmetricKeyHelper: + """Helper class for generating symmetric key JWT tokens for testing.""" + + def __init__(self, secret: str): + """Initialize with a secret key.""" + self.secret = secret + + def create_token( + self, + subject: str = "fastmcp-user", + issuer: str = "https://fastmcp.example.com", + audience: str | list[str] | None = None, + scopes: list[str] | None = None, + expires_in_seconds: int = 3600, + additional_claims: dict[str, Any] | None = None, + algorithm: str = "HS256", + ) -> str: + """ + Generate a test JWT token using symmetric key for testing purposes. + + Args: + subject: Subject claim (usually user ID) + issuer: Issuer claim + audience: Audience claim - can be a string or list of strings (optional) + scopes: List of scopes to include + expires_in_seconds: Token expiration time in seconds + additional_claims: Any additional claims to include + algorithm: JWT signing algorithm (HS256, HS384, or HS512) + """ + import time + + from authlib.jose import JsonWebToken + + # Create header + header = {"alg": algorithm} + + # Create payload + payload = { + "sub": subject, + "iss": issuer, + "iat": int(time.time()), + "exp": int(time.time()) + expires_in_seconds, + } + + if audience: + payload["aud"] = audience + + if scopes: + payload["scope"] = " ".join(scopes) + + if additional_claims: + payload.update(additional_claims) + + # Create JWT + jwt_lib = JsonWebToken([algorithm]) + token_bytes = jwt_lib.encode(header, payload, self.secret) + + return token_bytes.decode("utf-8") + + @pytest.fixture(scope="module") def rsa_key_pair() -> RSAKeyPair: return RSAKeyPair.generate() +@pytest.fixture(scope="module") +def symmetric_key_helper() -> SymmetricKeyHelper: + """Generate a symmetric key helper for testing.""" + return SymmetricKeyHelper("test-secret-key-for-hmac-signing") + + @pytest.fixture(scope="module") def bearer_token(rsa_key_pair: RSAKeyPair) -> str: return rsa_key_pair.create_token( @@ -34,6 +100,17 @@ def bearer_provider(rsa_key_pair: RSAKeyPair) -> JWTVerifier: ) +@pytest.fixture +def symmetric_provider(symmetric_key_helper: SymmetricKeyHelper) -> JWTVerifier: + """Create JWTVerifier configured for symmetric key verification.""" + return JWTVerifier( + public_key=symmetric_key_helper.secret, + issuer="https://test.example.com", + audience="https://api.example.com", + algorithm="HS256", + ) + + def run_mcp_server( public_key: str, host: str, @@ -104,6 +181,202 @@ class TestRSAKeyPair: # We'll validate the scopes in the BearerToken tests +class TestSymmetricKeyJWT: + """Tests for JWT verification using symmetric keys (HMAC algorithms).""" + + def test_initialization_with_symmetric_key( + self, symmetric_key_helper: SymmetricKeyHelper + ): + """Test JWTVerifier initialization with symmetric key.""" + provider = JWTVerifier( + public_key=symmetric_key_helper.secret, + issuer="https://test.example.com", + algorithm="HS256", + ) + + assert provider.issuer == "https://test.example.com" + assert provider.public_key == symmetric_key_helper.secret + assert provider.algorithm == "HS256" + assert provider.jwks_uri is None + + def test_initialization_with_different_symmetric_algorithms( + self, symmetric_key_helper: SymmetricKeyHelper + ): + """Test JWTVerifier initialization with different HMAC algorithms.""" + algorithms = ["HS256", "HS384", "HS512"] + + for algorithm in algorithms: + provider = JWTVerifier( + public_key=symmetric_key_helper.secret, + issuer="https://test.example.com", + algorithm=algorithm, + ) + assert provider.algorithm == algorithm + + async def test_valid_symmetric_token_validation( + self, symmetric_key_helper: SymmetricKeyHelper, symmetric_provider: JWTVerifier + ): + """Test validation of a valid token signed with symmetric key.""" + token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + scopes=["read", "write"], + algorithm="HS256", + ) + + access_token = await symmetric_provider.load_access_token(token) + + assert access_token is not None + assert access_token.client_id == "test-user" + assert "read" in access_token.scopes + assert "write" in access_token.scopes + assert access_token.expires_at is not None + + async def test_symmetric_token_with_different_algorithms( + self, symmetric_key_helper: SymmetricKeyHelper + ): + """Test that different HMAC algorithms work correctly.""" + algorithms = ["HS256", "HS384", "HS512"] + + for algorithm in algorithms: + provider = JWTVerifier( + public_key=symmetric_key_helper.secret, + issuer="https://test.example.com", + algorithm=algorithm, + ) + + token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + algorithm=algorithm, + ) + + access_token = await provider.load_access_token(token) + assert access_token is not None + assert access_token.client_id == "test-user" + + async def test_symmetric_token_issuer_validation( + self, symmetric_key_helper: SymmetricKeyHelper, symmetric_provider: JWTVerifier + ): + """Test issuer validation with symmetric key tokens.""" + # Valid issuer + valid_token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + access_token = await symmetric_provider.load_access_token(valid_token) + assert access_token is not None + + # Invalid issuer + invalid_token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://evil.example.com", + audience="https://api.example.com", + ) + access_token = await symmetric_provider.load_access_token(invalid_token) + assert access_token is None + + async def test_symmetric_token_audience_validation( + self, symmetric_key_helper: SymmetricKeyHelper, symmetric_provider: JWTVerifier + ): + """Test audience validation with symmetric key tokens.""" + # Valid audience + valid_token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + access_token = await symmetric_provider.load_access_token(valid_token) + assert access_token is not None + + # Invalid audience + invalid_token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://wrong-api.example.com", + ) + access_token = await symmetric_provider.load_access_token(invalid_token) + assert access_token is None + + async def test_symmetric_token_scope_extraction( + self, symmetric_key_helper: SymmetricKeyHelper, symmetric_provider: JWTVerifier + ): + """Test scope extraction from symmetric key tokens.""" + token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + scopes=["read", "write", "admin"], + ) + + access_token = await symmetric_provider.load_access_token(token) + assert access_token is not None + assert set(access_token.scopes) == {"read", "write", "admin"} + + async def test_symmetric_token_expiration( + self, symmetric_key_helper: SymmetricKeyHelper, symmetric_provider: JWTVerifier + ): + """Test expiration validation with symmetric key tokens.""" + # Valid token + valid_token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + expires_in_seconds=3600, # 1 hour from now + ) + access_token = await symmetric_provider.load_access_token(valid_token) + assert access_token is not None + + # Expired token + expired_token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + expires_in_seconds=-3600, # 1 hour ago + ) + access_token = await symmetric_provider.load_access_token(expired_token) + assert access_token is None + + async def test_symmetric_token_invalid_signature( + self, symmetric_key_helper: SymmetricKeyHelper, symmetric_provider: JWTVerifier + ): + """Test rejection of tokens with invalid signatures.""" + # Create a token with a different secret + other_helper = SymmetricKeyHelper("different-secret-key") + token = other_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + access_token = await symmetric_provider.load_access_token(token) + assert access_token is None + + async def test_symmetric_token_algorithm_mismatch( + self, symmetric_key_helper: SymmetricKeyHelper + ): + """Test that tokens with mismatched algorithms are rejected.""" + # Create provider expecting HS256 + provider = JWTVerifier( + public_key=symmetric_key_helper.secret, + issuer="https://test.example.com", + algorithm="HS256", + ) + + # Create token with HS512 + token = symmetric_key_helper.create_token( + subject="test-user", + issuer="https://test.example.com", + algorithm="HS512", + ) + + # Should fail because provider expects HS256 + access_token = await provider.load_access_token(token) + assert access_token is None + + class TestBearerTokenJWKS: """Tests for JWKS URI functionality.""" diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index be1b4f0bf..aa13ce43c 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -159,7 +159,7 @@ class TestOAuthProxyComprehensive: assert proxy._upstream_revocation_endpoint is None async def test_register_client(self, oauth_proxy): - """Test client registration always uses upstream credentials.""" + """Test client registration stores ProxyDCRClient without modifying original.""" client_info = OAuthClientInformationFull( client_id="original-client-id", client_secret="original-secret", @@ -170,20 +170,19 @@ class TestOAuthProxyComprehensive: await oauth_proxy.register_client(client_info) - # Verify client was modified to use upstream credentials - assert client_info.client_id == "test-client-id" - assert client_info.client_secret == "test-client-secret" - assert client_info.token_endpoint_auth_method == "none" - assert "authorization_code" in client_info.grant_types - # refresh_token is only added if grant_types was empty + assert client_info.client_id == "original-client-id" + assert client_info.client_secret == "original-secret" + assert client_info.token_endpoint_auth_method == "client_secret_post" + assert client_info.grant_types == ["authorization_code"] - # Verify client was stored + # Verify ProxyDCRClient was stored with upstream credentials stored_client = oauth_proxy._clients.get("test-client-id") assert stored_client is not None assert stored_client.client_id == "test-client-id" + assert stored_client.client_secret == "test-client-secret" async def test_register_client_empty_grant_types(self, oauth_proxy): - """Test client registration adds grant types when empty.""" + """Test client registration with empty grant types.""" client_info = OAuthClientInformationFull( client_id="original-client-id", client_secret="original-secret", @@ -193,8 +192,12 @@ class TestOAuthProxyComprehensive: await oauth_proxy.register_client(client_info) - # Should add both authorization_code and refresh_token - assert client_info.grant_types == ["authorization_code", "refresh_token"] + assert client_info.grant_types == [] + + # Verify stored ProxyDCRClient has proper grant types + stored_client = oauth_proxy._clients.get("test-client-id") + assert stored_client is not None + assert stored_client.grant_types == ["authorization_code", "refresh_token"] async def test_get_client_existing(self, oauth_proxy): """Test getting an existing registered client.""" diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py new file mode 100644 index 000000000..bfb8c8108 --- /dev/null +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -0,0 +1,191 @@ +"""Tests for OAuth proxy redirect URI validation.""" + +import pytest +from mcp.shared.auth import InvalidRedirectUriError +from pydantic import AnyUrl + +from fastmcp.server.auth.auth import TokenVerifier +from fastmcp.server.auth.oauth_proxy import OAuthProxy, ProxyDCRClient + + +class MockTokenVerifier(TokenVerifier): + """Mock token verifier for testing.""" + + def __init__(self): + self.required_scopes = [] + + async def verify_token(self, token: str) -> dict | None: + return {"sub": "test-user"} + + +class TestProxyDCRClient: + """Test ProxyDCRClient redirect URI validation.""" + + def test_default_localhost_only(self): + """Test that default configuration only allows localhost.""" + client = ProxyDCRClient( + client_id="test", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:3000")], + ) + + # Localhost should be allowed + assert client.validate_redirect_uri(AnyUrl("http://localhost:3000")) == AnyUrl( + "http://localhost:3000" + ) + assert client.validate_redirect_uri(AnyUrl("http://localhost:8080")) == AnyUrl( + "http://localhost:8080" + ) + assert client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000")) == AnyUrl( + "http://127.0.0.1:3000" + ) + + # Non-localhost should fallback to base validation + # This will check against registered redirect_uris + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://example.com")) + + def test_custom_patterns(self): + """Test custom redirect URI patterns.""" + client = ProxyDCRClient( + client_id="test", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:3000")], + allowed_redirect_uri_patterns=[ + "http://localhost:*", + "https://app.example.com/*", + ], + ) + + # Allowed by patterns + assert client.validate_redirect_uri(AnyUrl("http://localhost:3000")) + assert client.validate_redirect_uri(AnyUrl("https://app.example.com/callback")) + + # Not allowed by patterns - will fallback to base validation + with pytest.raises(InvalidRedirectUriError): + client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000")) + + def test_empty_list_allows_all(self): + """Test that empty pattern list allows all URIs.""" + client = ProxyDCRClient( + client_id="test", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:3000")], + allowed_redirect_uri_patterns=[], + ) + + # Everything should be allowed + assert client.validate_redirect_uri(AnyUrl("http://localhost:3000")) + assert client.validate_redirect_uri(AnyUrl("http://example.com")) + assert client.validate_redirect_uri(AnyUrl("https://anywhere.com:9999/path")) + + def test_none_redirect_uri(self): + """Test that None redirect URI uses default behavior.""" + client = ProxyDCRClient( + client_id="test", + client_secret="secret", + redirect_uris=[AnyUrl("http://localhost:3000")], + ) + + # None should use the first registered URI + result = client.validate_redirect_uri(None) + assert result == AnyUrl("http://localhost:3000") + + +class TestOAuthProxyRedirectValidation: + """Test OAuth proxy with redirect URI validation.""" + + def test_proxy_default_localhost_validation(self): + """Test that OAuth proxy defaults to localhost-only validation.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + ) + + # The proxy should store None for default localhost patterns + assert proxy._allowed_client_redirect_uris is None + + def test_proxy_custom_patterns(self): + """Test OAuth proxy with custom redirect patterns.""" + custom_patterns = ["http://localhost:*", "https://*.myapp.com/*"] + + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + allowed_client_redirect_uris=custom_patterns, + ) + + assert proxy._allowed_client_redirect_uris == custom_patterns + + def test_proxy_empty_list_validation(self): + """Test OAuth proxy with empty list (allow all).""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + allowed_client_redirect_uris=[], + ) + + assert proxy._allowed_client_redirect_uris == [] + + @pytest.mark.asyncio + async def test_proxy_register_client_uses_patterns(self): + """Test that registered clients use the configured patterns.""" + custom_patterns = ["https://app.example.com/*"] + + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + allowed_client_redirect_uris=custom_patterns, + ) + + # Register a client + from mcp.shared.auth import OAuthClientInformationFull + + client_info = OAuthClientInformationFull( + client_id="new-client", + client_secret="new-secret", + redirect_uris=[AnyUrl("https://app.example.com/callback")], + ) + + await proxy.register_client(client_info) + + # Get the registered client + registered = await proxy.get_client("test-client") # Uses upstream ID + assert isinstance(registered, ProxyDCRClient) + assert registered._allowed_redirect_uri_patterns == custom_patterns + + @pytest.mark.asyncio + async def test_proxy_unregistered_client_uses_patterns(self): + """Test that unregistered clients also use configured patterns.""" + custom_patterns = ["http://localhost:*", "http://127.0.0.1:*"] + + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + allowed_client_redirect_uris=custom_patterns, + ) + + # Get an unregistered client + client = await proxy.get_client("unknown-client") + assert isinstance(client, ProxyDCRClient) + assert client._allowed_redirect_uri_patterns == custom_patterns diff --git a/tests/server/auth/test_redirect_validation.py b/tests/server/auth/test_redirect_validation.py new file mode 100644 index 000000000..53d96e356 --- /dev/null +++ b/tests/server/auth/test_redirect_validation.py @@ -0,0 +1,124 @@ +"""Tests for redirect URI validation in OAuth flows.""" + +from pydantic import AnyUrl + +from fastmcp.server.auth.redirect_validation import ( + DEFAULT_LOCALHOST_PATTERNS, + matches_allowed_pattern, + validate_redirect_uri, +) + + +class TestMatchesAllowedPattern: + """Test wildcard pattern matching for redirect URIs.""" + + def test_exact_match(self): + """Test exact URI matching without wildcards.""" + assert matches_allowed_pattern( + "http://localhost:3000/callback", "http://localhost:3000/callback" + ) + assert not matches_allowed_pattern( + "http://localhost:3000/callback", "http://localhost:3001/callback" + ) + + def test_port_wildcard(self): + """Test wildcard matching for ports.""" + pattern = "http://localhost:*/callback" + assert matches_allowed_pattern("http://localhost:3000/callback", pattern) + assert matches_allowed_pattern("http://localhost:54321/callback", pattern) + assert not matches_allowed_pattern("http://example.com:3000/callback", pattern) + + def test_path_wildcard(self): + """Test wildcard matching for paths.""" + pattern = "http://localhost:3000/*" + assert matches_allowed_pattern("http://localhost:3000/callback", pattern) + assert matches_allowed_pattern("http://localhost:3000/auth/callback", pattern) + assert not matches_allowed_pattern("http://localhost:3001/callback", pattern) + + def test_subdomain_wildcard(self): + """Test wildcard matching for subdomains.""" + pattern = "https://*.example.com/callback" + assert matches_allowed_pattern("https://app.example.com/callback", pattern) + assert matches_allowed_pattern("https://api.example.com/callback", pattern) + assert not matches_allowed_pattern("https://example.com/callback", pattern) + assert not matches_allowed_pattern("http://app.example.com/callback", pattern) + + def test_multiple_wildcards(self): + """Test patterns with multiple wildcards.""" + pattern = "https://*.example.com:*/auth/*" + assert matches_allowed_pattern( + "https://app.example.com:8080/auth/callback", pattern + ) + assert matches_allowed_pattern( + "https://api.example.com:3000/auth/redirect", pattern + ) + assert not matches_allowed_pattern( + "http://app.example.com:8080/auth/callback", pattern + ) + + +class TestValidateRedirectUri: + """Test redirect URI validation with pattern lists.""" + + def test_none_redirect_uri_allowed(self): + """Test that None redirect URI is always allowed.""" + assert validate_redirect_uri(None, None) + assert validate_redirect_uri(None, []) + assert validate_redirect_uri(None, ["http://localhost:*"]) + + def test_default_localhost_patterns(self): + """Test default localhost-only patterns when None is provided.""" + # Localhost patterns should be allowed by default + assert validate_redirect_uri("http://localhost:3000", None) + assert validate_redirect_uri("http://127.0.0.1:8080", None) + + # Non-localhost should be rejected by default + assert not validate_redirect_uri("http://example.com", None) + assert not validate_redirect_uri("https://app.example.com", None) + + def test_empty_list_allows_all(self): + """Test that empty list allows all redirect URIs.""" + assert validate_redirect_uri("http://localhost:3000", []) + assert validate_redirect_uri("http://example.com", []) + assert validate_redirect_uri("https://anywhere.com:9999/path", []) + + def test_custom_patterns(self): + """Test validation with custom pattern list.""" + patterns = [ + "http://localhost:*", + "https://app.example.com/*", + "https://*.trusted.io/*", + ] + + # Allowed URIs + assert validate_redirect_uri("http://localhost:3000", patterns) + assert validate_redirect_uri("https://app.example.com/callback", patterns) + assert validate_redirect_uri("https://api.trusted.io/auth", patterns) + + # Rejected URIs + assert not validate_redirect_uri("http://127.0.0.1:3000", patterns) + assert not validate_redirect_uri("https://other.example.com/callback", patterns) + assert not validate_redirect_uri("http://app.example.com/callback", patterns) + + def test_anyurl_conversion(self): + """Test that AnyUrl objects are properly converted to strings.""" + patterns = ["http://localhost:*"] + uri = AnyUrl("http://localhost:3000/callback") + assert validate_redirect_uri(uri, patterns) + + uri = AnyUrl("http://example.com/callback") + assert not validate_redirect_uri(uri, patterns) + + +class TestDefaultPatterns: + """Test the default localhost patterns constant.""" + + def test_default_patterns_exist(self): + """Test that default patterns are defined.""" + assert DEFAULT_LOCALHOST_PATTERNS is not None + assert len(DEFAULT_LOCALHOST_PATTERNS) > 0 + + def test_default_patterns_include_localhost(self): + """Test that default patterns include localhost variations.""" + assert "http://localhost:*" in DEFAULT_LOCALHOST_PATTERNS + assert "http://127.0.0.1:*" in DEFAULT_LOCALHOST_PATTERNS diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py index 32bab98b2..561353084 100644 --- a/tests/server/proxy/test_proxy_server.py +++ b/tests/server/proxy/test_proxy_server.py @@ -87,7 +87,7 @@ async def test_create_proxy(fastmcp_server): assert isinstance(server, FastMCPProxy) assert isinstance(server, FastMCP) - assert server.name == "FastMCP" + assert server.name.startswith("FastMCPProxy-") async def test_as_proxy_with_server(fastmcp_server): diff --git a/tests/server/test_context.py b/tests/server/test_context.py index d386d99d2..9c00ac0e2 100644 --- a/tests/server/test_context.py +++ b/tests/server/test_context.py @@ -85,7 +85,7 @@ class TestParseModelPreferences: def test_parse_model_preferences_invalid_type(self, context): with pytest.raises(ValueError): - _parse_model_preferences(model_preferences=123) # pyright: ignore[reportArgumentType] + _parse_model_preferences(model_preferences=123) # pyright: ignore[reportArgumentType] # type: ignore[invalid-argument-type] class TestSessionId: @@ -97,7 +97,7 @@ class TestSessionId: mock_headers = {"mcp-session-id": "test-session-123"} token = request_ctx.set( - RequestContext( + RequestContext( # type: ignore[arg-type] request_id=0, meta=None, session=MagicMock(wraps={}), @@ -118,7 +118,7 @@ class TestSessionId: from mcp.shared.context import RequestContext token = request_ctx.set( - RequestContext( + RequestContext( # type: ignore[arg-type] request_id=0, meta=None, session=MagicMock(wraps={}), diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 2defe3578..8fde83446 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -290,18 +290,21 @@ class TestMultipleServerMount: # Use an unreachable port unreachable_client = Client( - transport=SSETransport("http://127.0.0.1:9999/sse/") + transport=SSETransport("http://127.0.0.1:9999/sse/"), + name="unreachable_client", ) # Create a proxy server that will fail to connect - unreachable_proxy = FastMCP.as_proxy(unreachable_client) + unreachable_proxy = FastMCP.as_proxy( + unreachable_client, name="unreachable_proxy" + ) # Mount the unreachable proxy main_app.mount(unreachable_proxy, "unreachable") # All object types should work from working server despite unreachable proxy with caplog_for_fastmcp(caplog): - async with Client(main_app) as client: + async with Client(main_app, name="main_app_client") as client: # Test tools tools = await client.list_tools() tool_names = [tool.name for tool in tools] @@ -326,17 +329,17 @@ class TestMultipleServerMount: record.message for record in caplog.records if record.levelname == "WARNING" ] assert any( - "Failed to get tools from server: 'FastMCP', mounted at: 'unreachable'" + "Failed to get tools from server: 'unreachable_proxy', mounted at: 'unreachable'" in msg for msg in warning_messages ) assert any( - "Failed to get resources from server: 'FastMCP', mounted at: 'unreachable'" + "Failed to get resources from server: 'unreachable_proxy', mounted at: 'unreachable'" in msg for msg in warning_messages ) assert any( - "Failed to get prompts from server: 'FastMCP', mounted at: 'unreachable'" + "Failed to get prompts from server: 'unreachable_proxy', mounted at: 'unreachable'" in msg for msg in warning_messages ) diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 827d3190d..0b90622d9 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -29,7 +29,7 @@ from fastmcp.utilities.tests import caplog_for_fastmcp, temporary_settings class TestCreateServer: async def test_create_server(self): mcp = FastMCP(instructions="Server instructions") - assert mcp.name == "FastMCP" + assert mcp.name.startswith("FastMCP-") assert mcp.instructions == "Server instructions" async def test_non_ascii_description(self): diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index 501bccf7f..f8e396b4f 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -3,6 +3,7 @@ import gc import inspect import logging import os +import sys import tempfile from collections.abc import AsyncGenerator from pathlib import Path @@ -243,7 +244,8 @@ async def test_multi_client(tmp_path: Path): @pytest.mark.skipif( - running_under_debugger(), reason="Debugger holds a reference to the transport" + running_under_debugger() or sys.platform.startswith("win32"), + reason="Debugger holds a reference to the transport; Windows has process lifecycle issues", ) @pytest.mark.timeout(5) async def test_multi_client_lifespan(tmp_path: Path): @@ -307,6 +309,10 @@ async def test_multi_client_lifespan(tmp_path: Path): await asyncio.sleep(0.1) +@pytest.mark.skipif( + sys.platform.startswith("win32"), + reason="Windows has process lifecycle issues", +) async def test_multi_client_force_close(tmp_path: Path): server_script = inspect.cleandoc(""" from fastmcp import FastMCP diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index a4583a060..7f8f2077e 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Annotated, Any import pytest +from dirty_equals import HasName +from inline_snapshot import snapshot from mcp.types import ( AudioContent, EmbeddedResource, @@ -13,7 +15,7 @@ from mcp.types import ( from pydantic import AnyUrl, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict -from fastmcp.tools.tool import Tool, _convert_to_content +from fastmcp.tools.tool import Tool, ToolResult, _convert_to_content from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.tests import caplog_for_fastmcp from fastmcp.utilities.types import Audio, File, Image @@ -29,20 +31,30 @@ class TestToolFromFunction: tool = Tool.from_function(add) - assert tool.name == "add" - assert tool.description == "Add two numbers." - assert len(tool.parameters["properties"]) == 2 - assert tool.parameters["properties"]["a"]["type"] == "integer" - assert tool.parameters["properties"]["b"]["type"] == "integer" - # With primitive wrapping, int return type becomes object with result property - expected_schema = { - "type": "object", - "properties": {"result": {"type": "integer", "title": "Result"}}, - "required": ["result"], - "title": "_WrappedResult", - "x-fastmcp-wrap-result": True, - } - assert tool.output_schema == expected_schema + assert tool.model_dump(exclude_none=True) == snapshot( + { + "name": "add", + "description": "Add two numbers.", + "tags": set(), + "enabled": True, + "parameters": { + "properties": { + "a": {"title": "A", "type": "integer"}, + "b": {"title": "B", "type": "integer"}, + }, + "required": ["a", "b"], + "type": "object", + }, + "output_schema": { + "properties": {"result": {"title": "Result", "type": "integer"}}, + "required": ["result"], + "title": "_WrappedResult", + "type": "object", + "x-fastmcp-wrap-result": True, + }, + "fn": HasName("add"), + } + ) def test_meta_parameter(self): """Test that meta parameter is properly handled.""" @@ -56,6 +68,7 @@ class TestToolFromFunction: assert tool.meta == meta_data mcp_tool = tool.to_mcp_tool() + # MCP tool includes fastmcp meta, so check that our meta is included assert mcp_tool.meta is not None assert meta_data.items() <= mcp_tool.meta.items() @@ -69,9 +82,27 @@ class TestToolFromFunction: tool = Tool.from_function(fetch_data) - assert tool.name == "fetch_data" - assert tool.description == "Fetch data from URL." - assert tool.parameters["properties"]["url"]["type"] == "string" + assert tool.model_dump(exclude_none=True) == snapshot( + { + "name": "fetch_data", + "description": "Fetch data from URL.", + "tags": set(), + "enabled": True, + "parameters": { + "properties": {"url": {"title": "Url", "type": "string"}}, + "required": ["url"], + "type": "object", + }, + "output_schema": { + "properties": {"result": {"title": "Result", "type": "string"}}, + "required": ["result"], + "title": "_WrappedResult", + "type": "object", + "x-fastmcp-wrap-result": True, + }, + "fn": HasName("fetch_data"), + } + ) def test_callable_object(self): class Adder: @@ -82,11 +113,30 @@ class TestToolFromFunction: return x + y tool = Tool.from_function(Adder()) - assert tool.name == "Adder" - assert tool.description == "Adds two numbers." - assert len(tool.parameters["properties"]) == 2 - assert tool.parameters["properties"]["x"]["type"] == "integer" - assert tool.parameters["properties"]["y"]["type"] == "integer" + + assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot( + { + "name": "Adder", + "description": "Adds two numbers.", + "tags": set(), + "enabled": True, + "parameters": { + "properties": { + "x": {"title": "X", "type": "integer"}, + "y": {"title": "Y", "type": "integer"}, + }, + "required": ["x", "y"], + "type": "object", + }, + "output_schema": { + "properties": {"result": {"title": "Result", "type": "integer"}}, + "required": ["result"], + "title": "_WrappedResult", + "type": "object", + "x-fastmcp-wrap-result": True, + }, + } + ) def test_async_callable_object(self): class Adder: @@ -97,11 +147,30 @@ class TestToolFromFunction: return x + y tool = Tool.from_function(Adder()) - assert tool.name == "Adder" - assert tool.description == "Adds two numbers." - assert len(tool.parameters["properties"]) == 2 - assert tool.parameters["properties"]["x"]["type"] == "integer" - assert tool.parameters["properties"]["y"]["type"] == "integer" + + assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot( + { + "name": "Adder", + "description": "Adds two numbers.", + "tags": set(), + "enabled": True, + "parameters": { + "properties": { + "x": {"title": "X", "type": "integer"}, + "y": {"title": "Y", "type": "integer"}, + }, + "required": ["x", "y"], + "type": "object", + }, + "output_schema": { + "properties": {"result": {"title": "Result", "type": "integer"}}, + "required": ["result"], + "title": "_WrappedResult", + "type": "object", + "x-fastmcp-wrap-result": True, + }, + } + ) def test_pydantic_model_function(self): """Test registering a function that takes a Pydantic model.""" @@ -116,20 +185,45 @@ class TestToolFromFunction: tool = Tool.from_function(create_user) - assert tool.name == "create_user" - assert tool.description == "Create a new user." - assert "name" in tool.parameters["$defs"]["UserInput"]["properties"] - assert "age" in tool.parameters["$defs"]["UserInput"]["properties"] - assert "flag" in tool.parameters["properties"] + assert tool.model_dump(exclude_none=True) == snapshot( + { + "name": "create_user", + "description": "Create a new user.", + "tags": set(), + "enabled": True, + "parameters": { + "$defs": { + "UserInput": { + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + }, + "required": ["name", "age"], + "title": "UserInput", + "type": "object", + } + }, + "properties": { + "user": {"$ref": "#/$defs/UserInput", "title": "User"}, + "flag": {"title": "Flag", "type": "boolean"}, + }, + "required": ["user", "flag"], + "type": "object", + }, + "output_schema": {"additionalProperties": True, "type": "object"}, + "fn": HasName("create_user"), + } + ) async def test_tool_with_image_return(self): def image_tool(data: bytes) -> Image: return Image(data=data) tool = Tool.from_function(image_tool) + assert tool.parameters["properties"]["data"]["type"] == "string" + assert tool.output_schema is None result = await tool.run({"data": "test.png"}) - assert tool.parameters["properties"]["data"]["type"] == "string" assert isinstance(result.content[0], ImageContent) async def test_tool_with_audio_return(self): @@ -137,9 +231,10 @@ class TestToolFromFunction: return Audio(data=data) tool = Tool.from_function(audio_tool) + assert tool.parameters["properties"]["data"]["type"] == "string" + assert tool.output_schema is None result = await tool.run({"data": "test.wav"}) - assert tool.parameters["properties"]["data"]["type"] == "string" assert isinstance(result.content[0], AudioContent) async def test_tool_with_file_return(self): @@ -147,15 +242,20 @@ class TestToolFromFunction: return File(data=data, format="octet-stream") tool = Tool.from_function(file_tool) - - result = await tool.run({"data": "test.bin"}) assert tool.parameters["properties"]["data"]["type"] == "string" - assert len(result.content) == 1 - assert isinstance(result.content[0], EmbeddedResource) - assert result.content[0].type == "resource" - assert hasattr(result.content[0], "resource") - resource = result.content[0].resource - assert resource.mimeType == "application/octet-stream" + assert tool.output_schema is None + + result: ToolResult = await tool.run({"data": "test.bin"}) + assert result.content[0].model_dump(exclude_none=True) == snapshot( + { + "type": "resource", + "resource": { + "uri": AnyUrl("file:///resource.octet-stream"), + "mimeType": "application/octet-stream", + "blob": "dGVzdC5iaW4=", + }, + } + ) def test_non_callable_fn(self): with pytest.raises(TypeError, match="not a callable object"): @@ -163,7 +263,18 @@ class TestToolFromFunction: def test_lambda(self): tool = Tool.from_function(lambda x: x, name="my_tool") - assert tool.name == "my_tool" + assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot( + { + "name": "my_tool", + "tags": set(), + "enabled": True, + "parameters": { + "properties": {"x": {"title": "X"}}, + "required": ["x"], + "type": "object", + }, + } + ) def test_lambda_with_no_name(self): with pytest.raises( @@ -177,8 +288,25 @@ class TestToolFromFunction: return _a + _b tool = Tool.from_function(add) - assert tool.parameters["properties"]["_a"]["type"] == "integer" - assert tool.parameters["properties"]["_b"]["type"] == "integer" + + assert tool.model_dump( + exclude_none=True, exclude={"output_schema", "fn"} + ) == snapshot( + { + "name": "add", + "description": "Add two numbers.", + "tags": set(), + "enabled": True, + "parameters": { + "properties": { + "_a": {"title": "A", "type": "integer"}, + "_b": {"title": "B", "type": "integer"}, + }, + "required": ["_a", "_b"], + "type": "object", + }, + } + ) def test_tool_with_varargs_not_allowed(self): def func(a: int, b: int, *args: int) -> int: @@ -209,10 +337,32 @@ class TestToolFromFunction: obj = MyClass() tool = Tool.from_function(obj.add) - assert tool.name == "add" - assert tool.description == "Add two numbers." assert "self" not in tool.parameters["properties"] + assert tool.model_dump(exclude_none=True, exclude={"fn"}) == snapshot( + { + "name": "add", + "description": "Add two numbers.", + "tags": set(), + "enabled": True, + "parameters": { + "properties": { + "x": {"title": "X", "type": "integer"}, + "y": {"title": "Y", "type": "integer"}, + }, + "required": ["x", "y"], + "type": "object", + }, + "output_schema": { + "properties": {"result": {"title": "Result", "type": "integer"}}, + "required": ["result"], + "title": "_WrappedResult", + "type": "object", + "x-fastmcp-wrap-result": True, + }, + } + ) + async def test_instance_method_with_varargs_not_allowed(self): class MyClass: def add(self, x: int, y: int, *args: int) -> int: @@ -322,6 +472,7 @@ class TestToolFromFunctionOutputSchema: "x-fastmcp-wrap-result": True, } assert tool.output_schema == expected_schema + # # Note: Parameterized test - keeping original assertion for multiple parameter values else: # Object types remain unwrapped assert tool.output_schema == base_schema @@ -339,8 +490,8 @@ class TestToolFromFunctionOutputSchema: return 1 tool = Tool.from_function(func) - base_schema = TypeAdapter(annotation).json_schema() + base_schema = TypeAdapter(annotation).json_schema() expected_schema = { "type": "object", "properties": {"result": {**base_schema, "title": "Result"}}, @@ -405,8 +556,18 @@ class TestToolFromFunctionOutputSchema: return Person(name="John", age=30) tool = Tool.from_function(func) - expected_schema = compress_schema(TypeAdapter(Person).json_schema()) - assert tool.output_schema == expected_schema + + assert tool.output_schema == snapshot( + { + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + }, + "required": ["name", "age"], + "title": "Person", + "type": "object", + } + ) async def test_typeddict_return_annotation(self): class Person(TypedDict): @@ -417,8 +578,17 @@ class TestToolFromFunctionOutputSchema: return Person(name="John", age=30) tool = Tool.from_function(func) - expected_schema = compress_schema(TypeAdapter(Person).json_schema()) - assert tool.output_schema == expected_schema + assert tool.output_schema == snapshot( + { + "properties": { + "name": {"title": "Name", "type": "string"}, + "age": {"title": "Age", "type": "integer"}, + }, + "required": ["name", "age"], + "title": "Person", + "type": "object", + } + ) async def test_unserializable_return_annotation(self): class Unserializable: @@ -593,14 +763,15 @@ class TestToolFromFunctionOutputSchema: # Don't specify output_schema - should infer and wrap tool = Tool.from_function(func) - expected_schema = { - "type": "object", - "properties": {"result": {"type": "integer", "title": "Result"}}, - "required": ["result"], - "title": "_WrappedResult", - "x-fastmcp-wrap-result": True, - } - assert tool.output_schema == expected_schema + assert tool.output_schema == snapshot( + { + "properties": {"result": {"title": "Result", "type": "integer"}}, + "required": ["result"], + "title": "_WrappedResult", + "type": "object", + "x-fastmcp-wrap-result": True, + } + ) result = await tool.run({}) assert result.structured_content == {"result": 42} @@ -665,14 +836,15 @@ class TestToolFromFunctionOutputSchema: # Inferred schema should wrap string type tool = Tool.from_function(func) - expected_schema = { - "type": "object", - "properties": {"result": {"type": "string", "title": "Result"}}, - "required": ["result"], - "title": "_WrappedResult", - "x-fastmcp-wrap-result": True, - } - assert tool.output_schema == expected_schema + assert tool.output_schema == snapshot( + { + "properties": {"result": {"title": "Result", "type": "string"}}, + "required": ["result"], + "title": "_WrappedResult", + "type": "object", + "x-fastmcp-wrap-result": True, + } + ) result = await tool.run({}) # Unstructured content diff --git a/tests/utilities/openapi/test_openapi.py b/tests/utilities/openapi/test_openapi.py index 6cbad7cad..f9d613281 100644 --- a/tests/utilities/openapi/test_openapi.py +++ b/tests/utilities/openapi/test_openapi.py @@ -1,12 +1,17 @@ """Tests for the OpenAPI parsing utilities.""" +from collections.abc import Sequence from typing import Any import pytest from fastapi import Body, FastAPI, Path, Query +from inline_snapshot import snapshot from pydantic import BaseModel, Field from fastmcp.utilities.openapi import ( + HttpMethod, + HTTPRoute, + ParameterInfo, _combine_schemas, _replace_ref_with_defs, parse_openapi_to_http_routes, @@ -107,7 +112,7 @@ def petstore_schema() -> dict[str, Any]: @pytest.fixture -def parsed_petstore_routes(petstore_schema): +def parsed_petstore_routes(petstore_schema: dict[str, Any]) -> list[HTTPRoute]: """Return parsed routes from the PetStore schema.""" return parse_openapi_to_http_routes(petstore_schema) @@ -214,11 +219,30 @@ def bookstore_schema() -> dict[str, Any]: @pytest.fixture -def parsed_bookstore_routes(bookstore_schema): +def parsed_bookstore_routes(bookstore_schema: dict[str, Any]) -> list[HTTPRoute]: """Return parsed routes from the BookStore schema.""" return parse_openapi_to_http_routes(bookstore_schema) +def get_route( + routes: list[HTTPRoute], method: HttpMethod, path: str +) -> HTTPRoute | None: + """Get a route by method and path.""" + return next((r for r in routes if r.method == method and r.path == path), None) + + +def get_parameter( + parameters: Sequence[ParameterInfo], name: str +) -> ParameterInfo | None: + """Get a parameter by name.""" + return next((p for p in parameters if p.name == name), None) + + +def dump_models(models: Sequence[BaseModel], **kwargs: Any) -> list[dict[str, Any]]: + """Dump a list of models to a list of dictionaries.""" + return [m.model_dump(**kwargs) for m in models] + + # --- FastAPI App Fixtures --- # @@ -302,13 +326,13 @@ def fastapi_openapi_schema(fastapi_app) -> dict[str, Any]: @pytest.fixture -def parsed_fastapi_routes(fastapi_openapi_schema): +def parsed_fastapi_routes(fastapi_openapi_schema: dict[str, Any]) -> list[HTTPRoute]: """Return parsed routes from a FastAPI OpenAPI schema.""" return parse_openapi_to_http_routes(fastapi_openapi_schema) @pytest.fixture -def fastapi_route_map(parsed_fastapi_routes): +def fastapi_route_map(parsed_fastapi_routes: list[HTTPRoute]) -> dict[str, HTTPRoute]: """Return a dictionary of routes by operation ID.""" return { r.operation_id: r for r in parsed_fastapi_routes if r.operation_id is not None @@ -484,128 +508,114 @@ def openapi_31_with_references() -> dict[str, Any]: # --- Tests for PetStore schema --- # -def test_petstore_route_count(parsed_petstore_routes): +def test_petstore_route_count(parsed_petstore_routes: list[HTTPRoute]): """Test that parsing the PetStore schema correctly identifies the number of routes.""" assert len(parsed_petstore_routes) == 3 -def test_petstore_get_pets_operation_id(parsed_petstore_routes): +def test_petstore_get_pets_operation_id(parsed_petstore_routes: list[HTTPRoute]): """Test that GET /pets operation_id is correctly parsed.""" - get_pets = next( - (r for r in parsed_petstore_routes if r.method == "GET" and r.path == "/pets"), - None, - ) + get_pets = get_route(parsed_petstore_routes, "GET", "/pets") assert get_pets is not None assert get_pets.operation_id == "listPets" -def test_petstore_query_parameter(parsed_petstore_routes): +def test_petstore_query_parameter(parsed_petstore_routes: list[HTTPRoute]): """Test that query parameter 'limit' is correctly parsed from the schema.""" - get_pets = next( - (r for r in parsed_petstore_routes if r.method == "GET" and r.path == "/pets"), - None, - ) + get_pets = get_route(parsed_petstore_routes, "GET", "/pets") assert get_pets is not None - assert len(get_pets.parameters) == 1 - param = get_pets.parameters[0] - assert param.name == "limit" - assert param.location == "query" - assert param.required is False - assert param.schema_.get("type") == "integer" - assert param.schema_.get("format") == "int32" + assert dump_models(get_pets.parameters, exclude_none=True) == snapshot( + [ + { + "name": "limit", + "location": "query", + "required": False, + "schema_": {"type": "integer", "format": "int32"}, + "description": "How many items to return", + } + ] + ) -def test_petstore_path_parameter(parsed_petstore_routes): +def test_petstore_path_parameter(parsed_petstore_routes: list[HTTPRoute]): """Test that path parameter 'petId' is correctly parsed from the schema.""" - get_pet = next( - ( - r - for r in parsed_petstore_routes - if r.method == "GET" and r.path == "/pets/{petId}" - ), - None, - ) - + get_pet = get_route(parsed_petstore_routes, "GET", "/pets/{petId}") assert get_pet is not None - path_param = next((p for p in get_pet.parameters if p.name == "petId"), None) + + path_param = get_parameter(get_pet.parameters, "petId") assert path_param is not None - assert path_param.location == "path" - assert path_param.required is True - assert path_param.schema_.get("type") == "string" + + assert path_param.model_dump(exclude_none=True) == snapshot( + { + "name": "petId", + "location": "path", + "required": True, + "schema_": {"type": "string"}, + "description": "The id of the pet", + } + ) -def test_petstore_header_parameters(parsed_petstore_routes): +def test_petstore_header_parameters(parsed_petstore_routes: list[HTTPRoute]): """Test that header parameters are correctly parsed from the schema.""" - get_pet = next( - ( - r - for r in parsed_petstore_routes - if r.method == "GET" and r.path == "/pets/{petId}" - ), - None, + get_pet = get_route(parsed_petstore_routes, "GET", "/pets/{petId}") + assert get_pet is not None + + header_params = [p for p in get_pet.parameters if p.location == "header"] + assert dump_models(header_params, exclude_none=True) == snapshot( + [ + { + "name": "X-Request-ID", + "location": "header", + "required": False, + "schema_": {"type": "string", "format": "uuid"}, + }, + { + "name": "traceId", + "location": "header", + "required": False, + "schema_": {"type": "string"}, + "description": "Common trace ID", + }, + ] ) - assert get_pet is not None - header_params = [p for p in get_pet.parameters if p.location == "header"] - assert len(header_params) == 2 - -def test_petstore_header_parameter_names(parsed_petstore_routes): - """Test that header parameter names are correctly parsed.""" - get_pet = next( - ( - r - for r in parsed_petstore_routes - if r.method == "GET" and r.path == "/pets/{petId}" - ), - None, - ) - - assert get_pet is not None - header_params = [p for p in get_pet.parameters if p.location == "header"] - header_names = [p.name for p in header_params] - assert "X-Request-ID" in header_names - assert "traceId" in header_names - - -def test_petstore_path_level_parameters(parsed_petstore_routes): +def test_petstore_path_level_parameters(parsed_petstore_routes: list[HTTPRoute]): """Test that path-level parameters are correctly merged into the operation.""" - get_pet = next( - ( - r - for r in parsed_petstore_routes - if r.method == "GET" and r.path == "/pets/{petId}" - ), - None, - ) - + get_pet = get_route(parsed_petstore_routes, "GET", "/pets/{petId}") assert get_pet is not None - trace_param = next((p for p in get_pet.parameters if p.name == "traceId"), None) + + trace_param = get_parameter(get_pet.parameters, "traceId") assert trace_param is not None - assert trace_param.location == "header" - assert trace_param.required is False - -def test_petstore_request_body_reference_resolution(parsed_petstore_routes): - """Test that request body references are correctly resolved.""" - create_pet = next( - (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"), - None, + assert trace_param.model_dump(exclude_none=True) == snapshot( + { + "name": "traceId", + "location": "header", + "required": False, + "schema_": {"type": "string"}, + "description": "Common trace ID", + } ) + +def test_petstore_request_body_reference_resolution( + parsed_petstore_routes: list[HTTPRoute], +): + """Test that request body references are correctly resolved.""" + create_pet = get_route(parsed_petstore_routes, "POST", "/pets") + assert create_pet is not None assert create_pet.request_body is not None assert create_pet.request_body.required is True assert "application/json" in create_pet.request_body.content_schema -def test_petstore_schema_reference_resolution(parsed_petstore_routes): +def test_petstore_schema_reference_resolution(parsed_petstore_routes: list[HTTPRoute]): """Test that schema references in request bodies are correctly resolved.""" - create_pet = next( - (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"), - None, - ) + create_pet = get_route(parsed_petstore_routes, "POST", "/pets") assert create_pet is not None assert create_pet.request_body is not None @@ -617,12 +627,9 @@ def test_petstore_schema_reference_resolution(parsed_petstore_routes): assert "tag" in properties -def test_petstore_required_fields_resolution(parsed_petstore_routes): +def test_petstore_required_fields_resolution(parsed_petstore_routes: list[HTTPRoute]): """Test that required fields are correctly resolved from referenced schemas.""" - create_pet = next( - (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"), - None, - ) + create_pet = get_route(parsed_petstore_routes, "POST", "/pets") assert create_pet is not None assert create_pet.request_body is not None @@ -630,7 +637,7 @@ def test_petstore_required_fields_resolution(parsed_petstore_routes): assert json_schema.get("required") == ["id", "name"] -def test_tags_parsing_in_petstore_routes(parsed_petstore_routes): +def test_tags_parsing_in_petstore_routes(parsed_petstore_routes: list[HTTPRoute]): """Test that tags are correctly parsed from the OpenAPI schema.""" # All petstore routes should have the "pets" tag for route in parsed_petstore_routes: @@ -639,7 +646,7 @@ def test_tags_parsing_in_petstore_routes(parsed_petstore_routes): ) -def test_tag_list_structure(parsed_petstore_routes): +def test_tag_list_structure(parsed_petstore_routes: list[HTTPRoute]): """Test that tags are stored as a list of strings.""" for route in parsed_petstore_routes: assert isinstance(route.tags, list), "Tags should be stored as a list" @@ -647,7 +654,7 @@ def test_tag_list_structure(parsed_petstore_routes): assert isinstance(tag, str), "Each tag should be a string" -def test_empty_tags_handling(bookstore_schema): +def test_empty_tags_handling(bookstore_schema: dict[str, Any]): """Test that routes with no tags are handled correctly with empty lists.""" # Modify a route to remove tags if "tags" in bookstore_schema["paths"]["/books"]["get"]: @@ -657,16 +664,14 @@ def test_empty_tags_handling(bookstore_schema): routes = parse_openapi_to_http_routes(bookstore_schema) # Find the GET /books route - get_books = next( - (r for r in routes if r.method == "GET" and r.path == "/books"), None - ) + get_books = get_route(routes, "GET", "/books") assert get_books is not None # Should have an empty list, not None assert get_books.tags == [], "Routes without tags should have empty tag lists" -def test_multiple_tags_preserved(bookstore_schema): +def test_multiple_tags_preserved(bookstore_schema: dict[str, Any]): """Test that multiple tags are preserved during parsing.""" # Add multiple tags to a route bookstore_schema["paths"]["/books"]["get"]["tags"] = ["books", "catalog", "api"] @@ -675,9 +680,7 @@ def test_multiple_tags_preserved(bookstore_schema): routes = parse_openapi_to_http_routes(bookstore_schema) # Find the GET /books route - get_books = next( - (r for r in routes if r.method == "GET" and r.path == "/books"), None - ) + get_books = get_route(routes, "GET", "/books") assert get_books is not None # Should have all tags @@ -687,7 +690,7 @@ def test_multiple_tags_preserved(bookstore_schema): assert len(get_books.tags) == 3 -def test_openapi_extensions(petstore_schema): +def test_openapi_extensions(petstore_schema: dict[str, Any]): """Test that OpenAPI extensions (x-*) are correctly parsed from operations.""" # Add extensions to a route petstore_schema["paths"]["/pets"]["get"]["x-rate-limit"] = 100 @@ -698,9 +701,7 @@ def test_openapi_extensions(petstore_schema): routes = parse_openapi_to_http_routes(petstore_schema) # Find the GET /pets route - get_pets = next( - (r for r in routes if r.method == "GET" and r.path == "/pets"), None - ) + get_pets = get_route(routes, "GET", "/pets") assert get_pets is not None # Should have extensions @@ -713,26 +714,22 @@ def test_openapi_extensions(petstore_schema): # --- Tests for BookStore schema --- # -def test_bookstore_route_count(parsed_bookstore_routes): +def test_bookstore_route_count(parsed_bookstore_routes: list[HTTPRoute]): """Test that parsing the BookStore schema correctly identifies the number of routes.""" assert len(parsed_bookstore_routes) == 4 -def test_bookstore_query_parameter_count(parsed_bookstore_routes): +def test_bookstore_query_parameter_count(parsed_bookstore_routes: list[HTTPRoute]): """Test that the correct number of query parameters are parsed.""" - list_books = next( - (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None - ) + list_books = get_route(parsed_bookstore_routes, "GET", "/books") assert list_books is not None assert len(list_books.parameters) == 3 -def test_bookstore_query_parameter_names(parsed_bookstore_routes): +def test_bookstore_query_parameter_names(parsed_bookstore_routes: list[HTTPRoute]): """Test that query parameter names are correctly parsed.""" - list_books = next( - (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None - ) + list_books = get_route(parsed_bookstore_routes, "GET", "/books") assert list_books is not None param_map = {p.name: p for p in list_books.parameters} @@ -741,33 +738,29 @@ def test_bookstore_query_parameter_names(parsed_bookstore_routes): assert "limit" in param_map -def test_bookstore_query_parameter_formats(parsed_bookstore_routes): +def test_bookstore_query_parameter_formats(parsed_bookstore_routes: list[HTTPRoute]): """Test that query parameter formats are correctly parsed.""" - list_books = next( - (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None - ) + list_books = get_route(parsed_bookstore_routes, "GET", "/books") assert list_books is not None param_map = {p.name: p for p in list_books.parameters} assert param_map["published_after"].schema_.get("format") == "date" -def test_bookstore_query_parameter_defaults(parsed_bookstore_routes): +def test_bookstore_query_parameter_defaults(parsed_bookstore_routes: list[HTTPRoute]): """Test that query parameter default values are correctly parsed.""" - list_books = next( - (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None - ) + list_books = get_route(parsed_bookstore_routes, "GET", "/books") assert list_books is not None param_map = {p.name: p for p in list_books.parameters} assert param_map["limit"].schema_.get("default") == 10 -def test_bookstore_inline_request_body_presence(parsed_bookstore_routes): +def test_bookstore_inline_request_body_presence( + parsed_bookstore_routes: list[HTTPRoute], +): """Test that request bodies with inline schemas are present.""" - create_book = next( - (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None - ) + create_book = get_route(parsed_bookstore_routes, "POST", "/books") assert create_book is not None assert create_book.request_body is not None @@ -775,31 +768,37 @@ def test_bookstore_inline_request_body_presence(parsed_bookstore_routes): assert "application/json" in create_book.request_body.content_schema -def test_bookstore_inline_request_body_properties(parsed_bookstore_routes): +def test_bookstore_inline_request_body_properties( + parsed_bookstore_routes: list[HTTPRoute], +): """Test that request body properties are correctly parsed from inline schemas.""" - create_book = next( - (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None - ) + create_book = get_route(parsed_bookstore_routes, "POST", "/books") assert create_book is not None assert create_book.request_body is not None json_schema = create_book.request_body.content_schema["application/json"] - properties = json_schema.get("properties", {}) - - assert "title" in properties - assert "author" in properties - assert "isbn" in properties - assert "published" in properties - assert "genre" in properties - - -def test_bookstore_inline_request_body_required_fields(parsed_bookstore_routes): - """Test that required fields in inline schema are correctly parsed.""" - create_book = next( - (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None + assert json_schema == snapshot( + { + "properties": { + "title": {"type": "string"}, + "author": {"type": "string"}, + "isbn": {"type": "string"}, + "published": {"type": "string", "format": "date"}, + "genre": {"type": "string"}, + }, + "type": "object", + "required": ["title", "author"], + } ) + +def test_bookstore_inline_request_body_required_fields( + parsed_bookstore_routes: list[HTTPRoute], +): + """Test that required fields in inline schema are correctly parsed.""" + create_book = get_route(parsed_bookstore_routes, "POST", "/books") + assert create_book is not None assert create_book.request_body is not None @@ -807,22 +806,18 @@ def test_bookstore_inline_request_body_required_fields(parsed_bookstore_routes): assert json_schema.get("required") == ["title", "author"] -def test_bookstore_delete_method(parsed_bookstore_routes): +def test_bookstore_delete_method(parsed_bookstore_routes: list[HTTPRoute]): """Test that DELETE method is correctly parsed from the schema.""" - delete_book = next( - (r for r in parsed_bookstore_routes if r.method == "DELETE"), None - ) + delete_book = get_route(parsed_bookstore_routes, "DELETE", "/books/{isbn}") assert delete_book is not None assert delete_book.operation_id == "deleteBook" assert delete_book.path == "/books/{isbn}" -def test_bookstore_delete_method_parameters(parsed_bookstore_routes): +def test_bookstore_delete_method_parameters(parsed_bookstore_routes: list[HTTPRoute]): """Test that parameters for DELETE method are correctly parsed.""" - delete_book = next( - (r for r in parsed_bookstore_routes if r.method == "DELETE"), None - ) + delete_book = get_route(parsed_bookstore_routes, "DELETE", "/books/{isbn}") assert delete_book is not None assert len(delete_book.parameters) == 1 @@ -832,12 +827,12 @@ def test_bookstore_delete_method_parameters(parsed_bookstore_routes): # --- Tests for FastAPI Generated Schema --- # -def test_fastapi_route_count(parsed_fastapi_routes): +def test_fastapi_route_count(parsed_fastapi_routes: list[HTTPRoute]): """Test that parsing a FastAPI-generated schema correctly identifies the number of routes.""" assert len(parsed_fastapi_routes) == 7 -def test_fastapi_parameter_default_values(fastapi_route_map): +def test_fastapi_parameter_default_values(fastapi_route_map: dict[str, HTTPRoute]): """Test that default parameter values are correctly parsed from the schema.""" list_items = fastapi_route_map["list_items"] @@ -846,7 +841,7 @@ def test_fastapi_parameter_default_values(fastapi_route_map): assert "limit" in param_map -def test_fastapi_skip_parameter_default(fastapi_route_map): +def test_fastapi_skip_parameter_default(fastapi_route_map: dict[str, HTTPRoute]): """Test that skip parameter default value is correctly parsed.""" list_items = fastapi_route_map["list_items"] @@ -854,7 +849,7 @@ def test_fastapi_skip_parameter_default(fastapi_route_map): assert param_map["skip"].schema_.get("default") == 0 -def test_fastapi_limit_parameter_default(fastapi_route_map): +def test_fastapi_limit_parameter_default(fastapi_route_map: dict[str, HTTPRoute]): """Test that limit parameter default value is correctly parsed.""" list_items = fastapi_route_map["list_items"] @@ -862,7 +857,7 @@ def test_fastapi_limit_parameter_default(fastapi_route_map): assert param_map["limit"].schema_.get("default") == 10 -def test_fastapi_request_body_from_pydantic(fastapi_route_map): +def test_fastapi_request_body_from_pydantic(fastapi_route_map: dict[str, HTTPRoute]): """Test that request bodies from Pydantic models are present.""" create_item = fastapi_route_map["create_item"] @@ -870,10 +865,12 @@ def test_fastapi_request_body_from_pydantic(fastapi_route_map): assert "application/json" in create_item.request_body.content_schema -def test_fastapi_request_body_properties(fastapi_route_map): +def test_fastapi_request_body_properties(fastapi_route_map: dict[str, HTTPRoute]): """Test that request body properties from Pydantic models are correctly parsed.""" create_item = fastapi_route_map["create_item"] + assert create_item.request_body is not None + json_schema = create_item.request_body.content_schema["application/json"] properties = json_schema.get("properties", {}) @@ -884,10 +881,12 @@ def test_fastapi_request_body_properties(fastapi_route_map): assert "tags" in properties -def test_fastapi_request_body_required_fields(fastapi_route_map): +def test_fastapi_request_body_required_fields(fastapi_route_map: dict[str, HTTPRoute]): """Test that required fields from Pydantic models are correctly parsed.""" create_item = fastapi_route_map["create_item"] + assert create_item.request_body is not None + json_schema = create_item.request_body.content_schema["application/json"] required = json_schema.get("required", []) @@ -895,7 +894,7 @@ def test_fastapi_request_body_required_fields(fastapi_route_map): assert "price" in required -def test_fastapi_path_parameter_presence(fastapi_route_map): +def test_fastapi_path_parameter_presence(fastapi_route_map: dict[str, HTTPRoute]): """Test that path parameters are present in FastAPI schema.""" get_item = fastapi_route_map["get_item"] @@ -903,7 +902,7 @@ def test_fastapi_path_parameter_presence(fastapi_route_map): assert len(path_params) == 1 -def test_fastapi_path_parameter_properties(fastapi_route_map): +def test_fastapi_path_parameter_properties(fastapi_route_map: dict[str, HTTPRoute]): """Test that path parameters properties are correctly parsed.""" get_item = fastapi_route_map["get_item"] @@ -912,7 +911,7 @@ def test_fastapi_path_parameter_properties(fastapi_route_map): assert path_params[0].required is True -def test_fastapi_optional_query_parameter(fastapi_route_map): +def test_fastapi_optional_query_parameter(fastapi_route_map: dict[str, HTTPRoute]): """Test that optional query parameters are correctly parsed.""" get_item = fastapi_route_map["get_item"] @@ -922,7 +921,7 @@ def test_fastapi_optional_query_parameter(fastapi_route_map): assert query_params[0].required is False -def test_fastapi_multiple_path_parameter_count(fastapi_route_map): +def test_fastapi_multiple_path_parameter_count(fastapi_route_map: dict[str, HTTPRoute]): """Test that multiple path parameters count is correct.""" get_item_tag = fastapi_route_map["get_item_tag"] @@ -930,7 +929,7 @@ def test_fastapi_multiple_path_parameter_count(fastapi_route_map): assert len(path_params) == 2 -def test_fastapi_multiple_path_parameter_names(fastapi_route_map): +def test_fastapi_multiple_path_parameter_names(fastapi_route_map: dict[str, HTTPRoute]): """Test that multiple path parameter names are correctly parsed.""" get_item_tag = fastapi_route_map["get_item_tag"] @@ -940,16 +939,41 @@ def test_fastapi_multiple_path_parameter_names(fastapi_route_map): assert "tag_id" in param_names -def test_fastapi_post_with_query_parameters(fastapi_route_map): +def test_fastapi_post_with_query_parameters(fastapi_route_map: dict[str, HTTPRoute]): """Test that query parameters for POST methods are correctly parsed.""" upload_file = fastapi_route_map["upload_file"] assert upload_file.method == "POST" query_params = [p for p in upload_file.parameters if p.location == "query"] - assert len(query_params) == 2 + assert dump_models(query_params, exclude_none=True) == snapshot( + [ + { + "name": "file_name", + "location": "query", + "required": True, + "schema_": { + "type": "string", + "title": "File Name", + "description": "Name of the file to upload", + }, + "description": "Name of the file to upload", + }, + { + "name": "content_type", + "location": "query", + "required": True, + "schema_": { + "type": "string", + "title": "Content Type", + "description": "Content type of the file", + }, + "description": "Content type of the file", + }, + ] + ) -def test_fastapi_post_query_parameter_names(fastapi_route_map): +def test_fastapi_post_query_parameter_names(fastapi_route_map: dict[str, HTTPRoute]): """Test that query parameter names for POST methods are correctly parsed.""" upload_file = fastapi_route_map["upload_file"] @@ -959,7 +983,7 @@ def test_fastapi_post_query_parameter_names(fastapi_route_map): assert "content_type" in param_names -def test_openapi_30_compatibility(openapi_30_schema): +def test_openapi_30_compatibility(openapi_30_schema: dict[str, Any]): """Test that OpenAPI 3.0 schemas can be parsed correctly.""" # This will raise an exception if the parser doesn't support 3.0.0 routes = parse_openapi_to_http_routes(openapi_30_schema) @@ -974,7 +998,7 @@ def test_openapi_30_compatibility(openapi_30_schema): assert route.parameters[0].name == "limit" -def test_openapi_31_compatibility(openapi_31_schema): +def test_openapi_31_compatibility(openapi_31_schema: dict[str, Any]): """Test that OpenAPI 3.1 schemas can be parsed correctly.""" routes = parse_openapi_to_http_routes(openapi_31_schema) @@ -1017,7 +1041,7 @@ def test_version_detection_logic(): pytest.fail(f"Failed to parse OpenAPI {version} schema: {e}") -def test_openapi_30_reference_resolution(openapi_30_with_references): +def test_openapi_30_reference_resolution(openapi_30_with_references: dict[str, Any]): """Test that references are correctly resolved in OpenAPI 3.0 schemas.""" routes = parse_openapi_to_http_routes(openapi_30_with_references) @@ -1031,30 +1055,46 @@ def test_openapi_30_reference_resolution(openapi_30_with_references): assert route.request_body.required is True assert "application/json" in route.request_body.content_schema - # Check schema structure + # Check schema structure with snapshots json_schema = route.request_body.content_schema["application/json"] - assert json_schema["type"] == "object" - assert "properties" in json_schema - assert set(json_schema["required"]) == {"name", "price"} - - # Check primary fields are properly resolved - props = json_schema["properties"] - assert "id" in props - assert "name" in props - assert "price" in props - assert "category" in props - - # The category might be a reference or resolved object - category = props["category"] - # Either it's directly resolved with properties - # or it still has a $ref field - assert "properties" in category or "$ref" in category + assert json_schema == snapshot( + { + "required": ["name", "price"], + "type": "object", + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, + "price": {"type": "number"}, + "category": {"$ref": "#/$defs/Category"}, + }, + } + ) combined_schema = _combine_schemas(route) - assert "#/$defs/" in combined_schema["properties"]["category"]["$ref"] + assert combined_schema == snapshot( + { + "type": "object", + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, + "price": {"type": "number"}, + "category": {"$ref": "#/$defs/Category"}, + }, + "required": ["name", "price"], + "$defs": { + "Category": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } + }, + } + ) -def test_openapi_31_reference_resolution(openapi_31_with_references): +def test_openapi_31_reference_resolution(openapi_31_with_references: dict[str, Any]): """Test that references are correctly resolved in OpenAPI 3.1 schemas.""" routes = parse_openapi_to_http_routes(openapi_31_with_references) @@ -1070,29 +1110,46 @@ def test_openapi_31_reference_resolution(openapi_31_with_references): # Check schema structure json_schema = route.request_body.content_schema["application/json"] - assert json_schema["type"] == "object" - assert "properties" in json_schema - assert set(json_schema["required"]) == {"name", "price"} - - # Check primary fields are properly resolved - props = json_schema["properties"] - assert "id" in props - assert "name" in props - assert "price" in props - assert "category" in props - - # The category might be a reference or resolved object - category = props["category"] - # Either it's directly resolved with properties - # or it still has a $ref field - assert "properties" in category or "$ref" in category + assert json_schema == snapshot( + { + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, + "price": {"type": "number"}, + "category": {"$ref": "#/$defs/Category"}, + }, + "type": "object", + "required": ["name", "price"], + } + ) combined_schema = _combine_schemas(route) - assert "#/$defs/" in combined_schema["properties"]["category"]["$ref"] + assert combined_schema == snapshot( + { + "type": "object", + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, + "price": {"type": "number"}, + "category": {"$ref": "#/$defs/Category"}, + }, + "required": ["name", "price"], + "$defs": { + "Category": { + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + "type": "object", + } + }, + } + ) def test_consistent_output_across_versions( - openapi_30_with_references, openapi_31_with_references + openapi_30_with_references: dict[str, Any], + openapi_31_with_references: dict[str, Any], ): """Test that both parsers produce equivalent output for equivalent schemas.""" routes_30 = parse_openapi_to_http_routes(openapi_30_with_references) diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py new file mode 100644 index 000000000..7d7899be7 --- /dev/null +++ b/tests/utilities/test_cli.py @@ -0,0 +1,270 @@ +from pathlib import Path +from unittest.mock import patch + +from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment + + +class TestEnvironmentBuildUVArgs: + """Test the Environment.build_uv_args() method.""" + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_basic(self, mock_dev_path): + """Test building basic uv args.""" + env = Environment() + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = ["run", "--with", "fastmcp", "fastmcp", "run", "server.py"] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_editable(self, mock_dev_path): + """Test building uv args with editable package.""" + editable_path = "/path/to/package" + env = Environment(editable=editable_path) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = [ + "run", + "--with", + "fastmcp", + "--with-editable", + editable_path, + "fastmcp", + "run", + "server.py", + ] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_packages(self, mock_dev_path): + """Test building uv args with additional packages.""" + env = Environment(dependencies=["pkg1", "pkg2"]) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = [ + "run", + "--with", + "fastmcp", + "--with", + "pkg1", + "--with", + "pkg2", + "fastmcp", + "run", + "server.py", + ] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_python_version(self, mock_dev_path): + """Test building uv args with Python version.""" + env = Environment(python="3.11") + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = [ + "run", + "--python", + "3.11", + "--with", + "fastmcp", + "fastmcp", + "run", + "server.py", + ] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_project(self, mock_dev_path): + """Test building uv args with project directory.""" + project_path = "/path/to/project" + env = Environment(project=project_path) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = [ + "run", + "--project", + project_path, + "--with", + "fastmcp", + "fastmcp", + "run", + "server.py", + ] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_requirements(self, mock_dev_path): + """Test building uv args with requirements file.""" + req_path = "requirements.txt" + env = Environment(requirements=req_path) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = [ + "run", + "--with", + "fastmcp", + "--with-requirements", + req_path, + "fastmcp", + "run", + "server.py", + ] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_all_options(self, mock_dev_path): + """Test building uv args with all options.""" + project_path = "/my/project" + editable_path = "/local/pkg" + requirements_path = "reqs.txt" + env = Environment( + python="3.10", + project=project_path, + dependencies=["pandas", "numpy"], + requirements=requirements_path, + editable=editable_path, + ) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = [ + "run", + "--python", + "3.10", + "--project", + project_path, + "--with", + "fastmcp", + "--with", + "pandas", + "--with", + "numpy", + "--with-requirements", + requirements_path, + "--with-editable", + editable_path, + "fastmcp", + "run", + "server.py", + ] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_no_command(self, mock_dev_path): + """Test building uv args with no command.""" + env = Environment(python="3.11") + args = env.build_uv_args() + expected = ["run", "--python", "3.11", "--with", "fastmcp"] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_string_command(self, mock_dev_path): + """Test building uv args with string command.""" + env = Environment() + args = env.build_uv_args("python") + expected = ["run", "--with", "fastmcp", "python"] + assert args == expected + + def test_needs_uv_true(self): + """Test that needs_uv returns True when environment settings are present.""" + env = Environment(python="3.11") + assert env.needs_uv() is True + + env = Environment(dependencies=["pkg"]) + assert env.needs_uv() is True + + env = Environment(requirements="reqs.txt") + assert env.needs_uv() is True + + env = Environment(project="/project") + assert env.needs_uv() is True + + env = Environment(editable="/pkg") + assert env.needs_uv() is True + + def test_needs_uv_false(self): + """Test that needs_uv returns False when no environment settings are present.""" + env = Environment() + assert env.needs_uv() is False + + @patch.object(Environment, "_find_fastmcp_dev_path") + def test_build_uv_args_development_mode(self, mock_dev_path): + """Test building uv args in development mode (when fastmcp project is found).""" + # Mock finding the development path + dev_path = Path("/path/to/fastmcp/dev") + mock_dev_path.return_value = dev_path + + env = Environment() + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = [ + "run", + "--with-editable", + str(dev_path), + "fastmcp", + "run", + "server.py", + ] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path") + def test_build_uv_args_production_mode(self, mock_dev_path): + """Test building uv args in production mode (when no fastmcp project is found).""" + # Mock not finding the development path + mock_dev_path.return_value = None + + env = Environment() + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = ["run", "--with", "fastmcp", "fastmcp", "run", "server.py"] + assert args == expected + + @patch("pathlib.Path.cwd") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.read_text") + def test_find_fastmcp_dev_path_found(self, mock_read_text, mock_exists, mock_cwd): + """Test finding fastmcp development path when pyproject.toml exists.""" + # Set up mock current directory + mock_cwd_path = Path("/path/to/fastmcp") + mock_cwd.return_value = mock_cwd_path + + # Mock pyproject.toml exists and contains fastmcp name + mock_exists.return_value = True + mock_read_text.return_value = """[project] +name = "fastmcp" +version = "2.0.0" +""" + + env = Environment() + result = env._find_fastmcp_dev_path() + + assert result == mock_cwd_path + + @patch("pathlib.Path.cwd") + @patch("pathlib.Path.exists") + def test_find_fastmcp_dev_path_not_found(self, mock_exists, mock_cwd): + """Test not finding fastmcp development path when no pyproject.toml exists.""" + # Set up mock current directory + mock_cwd_path = Path("/some/other/directory") + mock_cwd.return_value = mock_cwd_path + + # Mock pyproject.toml doesn't exist + mock_exists.return_value = False + + env = Environment() + result = env._find_fastmcp_dev_path() + + assert result is None + + @patch("pathlib.Path.cwd") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.read_text") + def test_find_fastmcp_dev_path_wrong_project( + self, mock_read_text, mock_exists, mock_cwd + ): + """Test not finding fastmcp when pyproject.toml exists but is for different project.""" + # Set up mock current directory + mock_cwd_path = Path("/path/to/other/project") + mock_cwd.return_value = mock_cwd_path + + # Mock pyproject.toml exists but is for different project + mock_exists.return_value = True + mock_read_text.return_value = """[project] +name = "other-project" +version = "1.0.0" +""" + + env = Environment() + result = env._find_fastmcp_dev_path() + + assert result is None diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index 4d5d8a1e9..65ba9b2e3 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -74,6 +74,7 @@ class TestPruneUnusedDefs: prune_additional_properties=False, prune_titles=False, ) + assert "foo_def" in result["$defs"] assert "unused_def" not in result["$defs"] diff --git a/uv.lock b/uv.lock index 2f7f8c88a..65c81ac8f 100644 --- a/uv.lock +++ b/uv.lock @@ -231,10 +231,9 @@ wheels = [ [[package]] name = "copychat" -version = "0.7.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastmcp" }, { name = "gitpython" }, { name = "pathspec" }, { name = "pyperclip" }, @@ -242,9 +241,9 @@ dependencies = [ { name = "tiktoken" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/77/a72f890207b33eb542e9507a9d167e8ff734080a9d265472d7a774bd46e4/copychat-0.7.2.tar.gz", hash = "sha256:3f8c21039f0f8874fb84d2163e467e2e003ac625d218225800732244c55176fa", size = 95779, upload-time = "2025-06-19T18:20:27.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/d9/112fd77fdc21e89dee79583d326edca3597493be5666281b87e393de2cf9/copychat-0.6.3.tar.gz", hash = "sha256:39ffb493506f20e72d26673490d5a7228cf40f3712d6a60ad6a9ac9f7106f5e4", size = 78328, upload-time = "2025-06-03T15:53:13.368Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/b7/266a72b4e843c61bffe2082539c7b634bbe42dd47d8110a5c15e3ee8d66a/copychat-0.7.2-py3-none-any.whl", hash = "sha256:ac2dcb86b70abeb5f8483fc6c70695c93c60b4e851a5b57b165edd36f3e15e8c", size = 23920, upload-time = "2025-06-19T18:20:26.405Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/e2f61f7bba857850b5022ce609ba9fcff8308458f1608ec20b35132263b9/copychat-0.6.3-py3-none-any.whl", hash = "sha256:1460cd02c09b6495550f6a4aa2ab0bacbf2b95176e876fc268b35d22243a4d97", size = 21617, upload-time = "2025-06-03T15:53:11.378Z" }, ] [[package]] @@ -561,6 +560,7 @@ dev = [ { name = "copychat" }, { name = "dirty-equals" }, { name = "fastapi" }, + { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pdbpp" }, @@ -604,6 +604,7 @@ dev = [ { name = "copychat", specifier = ">=0.5.2" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, + { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, { name = "pdbpp", specifier = ">=0.10.3" }, { name = "pre-commit" }, @@ -729,6 +730,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "inline-snapshot" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/9e/83aaa750e9c8115d34b2d80646c1988941f2252c5548caf35aad5e529bad/inline_snapshot-0.28.0.tar.gz", hash = "sha256:6904bfc383240b6bea64de2f5d2992f04109b13def19395bdd13fb0ebcf5cf20", size = 348554, upload-time = "2025-08-24T21:48:04.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/04/190b336a006d4e1275c2dde1bf953336e818d18b779f24947579bb4ba48d/inline_snapshot-0.28.0-py3-none-any.whl", hash = "sha256:9988f82ee5e719445bbc437d0dc01e0a3c4c94f0ba910f8ad8b573cf15aa8348", size = 69026, upload-time = "2025-08-24T21:48:02.342Z" }, +] + +[package.optional-dependencies] +dirty-equals = [ + { name = "dirty-equals" }, +] + [[package]] name = "ipython" version = "8.37.0"